diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 078da8380..388d13f78 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -75,11 +75,13 @@ 3C14E3B32FAE54C006ED053 /* OSLoggerPlatformProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C14E3AC2FAE54C006ED053 /* OSLoggerPlatformProvider.swift */; }; 3C14E3B42FAE54C006ED053 /* KotlinByteArray+Data.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C14E3AD2FAE54C006ED053 /* KotlinByteArray+Data.swift */; }; 3C14E3B52FAE54C006ED053 /* OSLoggerAdaptersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C14E3AE2FAE54C006ED053 /* OSLoggerAdaptersTests.swift */; }; + C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */; }; B96A3B6BA8CC49EE4796D9BF /* OSRemoteLoggingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */; }; 25898119922BDCDA7AF0B9CC /* OSRemoteLoggingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */; }; 9EAF92032D0429FA35E04417 /* OSRemoteLoggingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */; }; ACE2175908241BB46C9F1829 /* OSRemoteLoggingControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 658E6E9E6BC6BBF702BCBD33 /* OSRemoteLoggingControllerTests.swift */; }; 7732574D325D34CC7C498199 /* OSRemoteLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6972EE491A57C79EFE56D4C8 /* OSRemoteLogger.swift */; }; + 698F58A488FCE503DFD5247F /* OSLogCrashHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACD3284210A7DF2597594778 /* OSLogCrashHandler.swift */; }; 3C19C6322E919F0C00D6731E /* OSRequestLiveActivityClicked.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C19C6312E919F0C00D6731E /* OSRequestLiveActivityClicked.swift */; }; 3C23A21B2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */; }; 3C23A21D2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C23A21C2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift */; }; @@ -1796,10 +1798,12 @@ 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 = ""; }; + 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLogCrashHandlerTests.swift; sourceTree = ""; }; 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRemoteLoggingController.swift; sourceTree = ""; }; C0462F96E1AADF655F3B3765 /* OSRemoteLoggingController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OSRemoteLoggingController.h; sourceTree = ""; }; 658E6E9E6BC6BBF702BCBD33 /* OSRemoteLoggingControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRemoteLoggingControllerTests.swift; sourceTree = ""; }; 6972EE491A57C79EFE56D4C8 /* OSRemoteLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRemoteLogger.swift; sourceTree = ""; }; + ACD3284210A7DF2597594778 /* OSLogCrashHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLogCrashHandler.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 = ""; }; @@ -2295,6 +2299,7 @@ 3C14E3AB2FAE54C006ED053 /* FileLogStore.swift */, 3C14E3AA2FAE54C006ED053 /* OneSignalLogHttpSender.swift */, 3C14E3A92FAE54C006ED053 /* IOSLogger.swift */, + ACD3284210A7DF2597594778 /* OSLogCrashHandler.swift */, 3C14E3AC2FAE54C006ED053 /* OSLoggerPlatformProvider.swift */, 6972EE491A57C79EFE56D4C8 /* OSRemoteLogger.swift */, ); @@ -2569,6 +2574,7 @@ 5BC1DE672C90C23E00CA8807 /* OSConsistencyManagerTests.swift */, 3C427AC8301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift */, 3C14E3AE2FAE54C006ED053 /* OSLoggerAdaptersTests.swift */, + 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */, 3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */, 3C23A21E2FCE0AA1001D32E3 /* OSResilientStorageTests.swift */, 3C23A21C2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift */, @@ -4442,6 +4448,7 @@ 3C14E3B12FAE54C006ED053 /* OneSignalLogHttpSender.swift in Sources */, 3C4F9E4428A4466C009F453A /* OSOperationRepo.swift in Sources */, 3C14E3B02FAE54C006ED053 /* IOSLogger.swift in Sources */, + 698F58A488FCE503DFD5247F /* OSLogCrashHandler.swift in Sources */, 7732574D325D34CC7C498199 /* OSRemoteLogger.swift in Sources */, 3C11518B289ADEEB00565C41 /* OSEventProducer.swift in Sources */, 3C115165289A259500565C41 /* OneSignalOSCore.docc in Sources */, @@ -4604,6 +4611,7 @@ 3C23A21D2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift in Sources */, 3C427AC9301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift in Sources */, 3C14E3B52FAE54C006ED053 /* OSLoggerAdaptersTests.swift in Sources */, + C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */, 3C23A21B2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSLogCrashHandler.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSLogCrashHandler.swift new file mode 100644 index 000000000..5c941f53f --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSLogCrashHandler.swift @@ -0,0 +1,289 @@ +/* + 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. + */ + +// Kotlin/Native does not produce a Mac Catalyst framework slice. +#if !targetEnvironment(macCatalyst) + +import Darwin +import Foundation +@_implementationOnly import OneSignalKMP + +private typealias OSExceptionHandler = @convention(c) (NSException) -> Void + +private func osLogUncaughtExceptionHandler(_ exception: NSException) { + OSLogCrashHandler.handleActive(exception) +} + +struct OSResolvedStackFrame: Equatable { + let imagePath: String? + let symbolName: String? +} + +final class OSCrashLogger: ILogger { + func error(message: String) { + NSLog("[OneSignal crash] ERROR: %@", message) + } + + func warn(message: String) { + NSLog("[OneSignal crash] WARN: %@", message) + } + + func info(message: String) { + NSLog("[OneSignal crash] INFO: %@", message) + } + + func debug(message: String) { + NSLog("[OneSignal crash] DEBUG: %@", message) + } +} + +/// Captures uncaught Objective-C exceptions through the synchronous KMP crash +/// reporter before forwarding to the handler that was previously installed. +/// +/// POSIX signals are intentionally not intercepted because Swift, Foundation, +/// Kotlin/Native, and the durable file store are not async-signal-safe. +final class OSLogCrashHandler: ILogCrashHandler { + private static let oneSignalModules: Set = [ + "OneSignal", + "OneSignalCore", + "OneSignalExtension", + "OneSignalFramework", + "OneSignalInAppMessages", + "OneSignalKMP", + "OneSignalLiveActivities", + "OneSignalLocation", + "OneSignalNotifications", + "OneSignalOSCore", + "OneSignalOutcomes", + "OneSignalUser" + ] + private static let registryLock = NSLock() + private static let handlingThreadKey = "com.onesignal.logger.handling-exception" + private static var active: OSLogCrashHandler? + private static var inactivePreviousHandler: OSExceptionHandler? + + private let reporter: ILogCrashReporter + private var previousExceptionHandler: OSExceptionHandler? + private var isInitialized = false + + init(reporter: ILogCrashReporter) { + self.reporter = reporter + } + + func initialize() { + Self.registryLock.lock() + defer { Self.registryLock.unlock() } + guard !isInitialized else { + return + } + guard Self.active == nil else { + return + } + + previousExceptionHandler = NSGetUncaughtExceptionHandler() + Self.inactivePreviousHandler = previousExceptionHandler + Self.active = self + NSSetUncaughtExceptionHandler(osLogUncaughtExceptionHandler) + isInitialized = true + } + + func unregister() { + Self.registryLock.lock() + defer { Self.registryLock.unlock() } + guard isInitialized else { + return + } + + let isCurrentHandler = Self.exceptionHandlerAddress(NSGetUncaughtExceptionHandler()) + == Self.exceptionHandlerAddress(osLogUncaughtExceptionHandler) + if Self.active === self, isCurrentHandler { + NSSetUncaughtExceptionHandler(previousExceptionHandler) + } + if Self.active === self { + Self.active = nil + } + isInitialized = false + } + + func handle(exception: NSException) { + handle( + exception: exception, + stackSymbols: exception.callStackSymbols, + resolvedFrames: Self.resolveStackFrames(exception.callStackReturnAddresses) + ) + } + + func handle( + exception: NSException, + stackSymbols: [String], + resolvedFrames: [OSResolvedStackFrame] + ) { + guard Self.isOneSignalAtFault(resolvedFrames) else { + previousExceptionHandler?(exception) + return + } + capture( + exceptionType: exception.name.rawValue, + exceptionMessage: exception.reason ?? exception.description, + stacktrace: stackSymbols.joined(separator: "\n") + ) + previousExceptionHandler?(exception) + } + + private func capture( + exceptionType: String, + exceptionMessage: String, + stacktrace: String + ) { + let crash = CrashData( + threadName: Self.currentThreadName, + exceptionType: exceptionType, + exceptionMessage: exceptionMessage, + stacktrace: stacktrace + ) + do { + try reporter.saveCrash(crash: crash) + } catch { + NSLog("[OneSignal crash] Unable to persist fatal crash: %@", error.localizedDescription) + } + } + + private static var currentThreadName: String { + if let name = Thread.current.name, !name.isEmpty { + return name + } + if Thread.isMainThread { + return "main" + } + var name = [CChar](repeating: 0, count: 64) + guard pthread_getname_np(pthread_self(), &name, name.count) == 0 else { + return "unknown" + } + let threadName = String(cString: name) + return threadName.isEmpty ? "unknown" : threadName + } + + private static func exceptionHandlerAddress(_ handler: OSExceptionHandler?) -> UInt { + handler.map { unsafeBitCast($0, to: UInt.self) } ?? 0 + } + + static func handleActive(_ exception: NSException) { + let threadDictionary = Thread.current.threadDictionary + guard threadDictionary[handlingThreadKey] == nil else { + return + } + threadDictionary[handlingThreadKey] = true + defer { threadDictionary.removeObject(forKey: handlingThreadKey) } + + registryLock.lock() + let handler = active + let previousHandler = inactivePreviousHandler + registryLock.unlock() + if let handler { + handler.handle(exception: exception) + } else { + previousHandler?(exception) + } + } + + static func isOneSignalAtFault(_ frames: [OSResolvedStackFrame]) -> Bool { + frames.contains { frame in + guard let imagePath = frame.imagePath, + !isSystemImage(imagePath) else { + return false + } + if oneSignalModules.contains(imageName(from: imagePath)) { + return true + } + guard let symbolName = frame.symbolName else { + return false + } + return isOneSignalSymbol(symbolName) + } + } + + private static func resolveStackFrames(_ addresses: [NSNumber]) -> [OSResolvedStackFrame] { + addresses.map { address in + guard let pointer = UnsafeRawPointer(bitPattern: address.uintValue) else { + return OSResolvedStackFrame(imagePath: nil, symbolName: nil) + } + var info = Dl_info() + guard dladdr(pointer, &info) != 0 else { + return OSResolvedStackFrame(imagePath: nil, symbolName: nil) + } + return OSResolvedStackFrame( + imagePath: info.dli_fname.map { String(cString: $0) }, + symbolName: info.dli_sname.map { String(cString: $0) } + ) + } + } + + private static func imageName(from path: String) -> String { + path.split(separator: "/").last.map(String.init) ?? path + } + + private static func isSystemImage(_ path: String) -> Bool { + path.contains("/System/Library/") || path.contains("/usr/lib/") + } + + private static func isOneSignalSymbol(_ symbol: String) -> Bool { + let symbolWithoutLeadingUnderscores = symbol.drop(while: { $0 == "_" }) + if symbolWithoutLeadingUnderscores.hasPrefix("-[OneSignal") + || symbolWithoutLeadingUnderscores.hasPrefix("+[OneSignal") + || symbolWithoutLeadingUnderscores.hasPrefix("onesignal_") + || symbolWithoutLeadingUnderscores.contains("kfun:com.onesignal.") { + return true + } + guard let module = swiftModuleName(from: String(symbolWithoutLeadingUnderscores)) else { + return false + } + return oneSignalModules.contains(module) + } + + private static func swiftModuleName(from symbol: String) -> String? { + guard symbol.hasPrefix("$s") else { + return nil + } + let moduleLengthStart = symbol.index(symbol.startIndex, offsetBy: 2) + var moduleNameStart = moduleLengthStart + while moduleNameStart < symbol.endIndex, symbol[moduleNameStart].isNumber { + moduleNameStart = symbol.index(after: moduleNameStart) + } + guard moduleNameStart > moduleLengthStart, + let moduleLength = Int(symbol[moduleLengthStart.. Void) func shutdown() @@ -47,15 +48,123 @@ public protocol OSStructuredRemoteLoggerProtocol: OSRemoteLoggerProtocol { ) } +public extension OSRemoteLoggerProtocol { + func start() {} +} + #if !targetEnvironment(macCatalyst) @_implementationOnly import OneSignalKMP +private final class OSRemoteLoggerLifecycle { + private let lock = NSLock() + private var isStarted = false + private var isShuttingDown = false + private var isShutdown = false + + var canStartUploader: Bool { + lock.lock() + defer { lock.unlock() } + return isStarted && !isShuttingDown && !isShutdown + } + + func performIfTransportActive(_ work: () -> Void) -> Bool { + lock.lock() + defer { lock.unlock() } + guard isStarted, !isShutdown else { + return false + } + work() + return true + } + + func start() -> Bool { + lock.lock() + defer { lock.unlock() } + guard !isStarted, !isShutdown else { + return false + } + isStarted = true + return true + } + + func beginShutdown() -> Bool { + lock.lock() + defer { lock.unlock() } + guard !isShuttingDown, !isShutdown else { + return false + } + isShuttingDown = true + return true + } + + func finishShutdown() { + lock.lock() + isShutdown = true + lock.unlock() + } +} + +final class OSCrashUploaderCoordinator { + static let shared = OSCrashUploaderCoordinator() + + private struct PendingUpload { + let owner: UUID + let start: () -> Void + } + + private let lock = NSLock() + private var activeOwner: UUID? + private var pendingUploads: [PendingUpload] = [] + + func enqueue(owner: UUID, start: @escaping () -> Void) { + lock.lock() + if activeOwner == nil { + activeOwner = owner + lock.unlock() + start() + return + } + pendingUploads.removeAll { $0.owner == owner } + pendingUploads.append(PendingUpload(owner: owner, start: start)) + lock.unlock() + } + + func cancel(owner: UUID) { + lock.lock() + pendingUploads.removeAll { $0.owner == owner } + lock.unlock() + } + + func finish(owner: UUID) { + lock.lock() + guard activeOwner == owner else { + lock.unlock() + return + } + guard !pendingUploads.isEmpty else { + activeOwner = nil + lock.unlock() + return + } + let next = pendingUploads.removeFirst() + activeOwner = next.owner + lock.unlock() + next.start() + } +} + /// Owns the KMP-specific logger composition while exposing a platform-neutral /// lifecycle API to the umbrella framework. public final class OSRemoteLogger: OSRemoteLoggerProtocol { private let telemetry: ILogTelemetryRemote private let platformProvider: OSLoggerPlatformProvider + private let crashHandler: ILogCrashHandler + private let crashUploader: LogCrashUploader + private let logger: IOSLogger + private let lifecycle: OSRemoteLoggerLifecycle + private let lifecycleOperationLock = NSLock() + private let uploaderOwner = UUID() public init( installIdProvider: @escaping () -> String, @@ -76,14 +185,68 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol { exporterLoggingEnabledProvider: exporterLoggingEnabledProvider ) let logger = IOSLogger() - self.platformProvider = provider - self.telemetry = LoggerFactory.shared.createRemoteTelemetry( + let crashLogger = OSCrashLogger() + let lifecycle = OSRemoteLoggerLifecycle() + let fileStore = FileLogStore(rootPath: provider.crashStoragePath) + let remoteTelemetry = LoggerFactory.shared.createRemoteTelemetry( platformProvider: provider, httpSender: OneSignalLogHttpSender( logger: logger, - isDiagnosticsEnabled: exporterLoggingEnabledProvider + isDiagnosticsEnabled: exporterLoggingEnabledProvider, + executeIfEnabled: { work in + lifecycle.performIfTransportActive(work) + } ) ) + let crashTelemetry = LoggerFactory.shared.createCrashLocalTelemetry( + platformProvider: provider, + fileStore: fileStore + ) + let crashReporter = LoggerFactory.shared.createCrashReporter( + crashTelemetry: crashTelemetry, + logger: crashLogger + ) + let crashHandler = OSLogCrashHandler(reporter: crashReporter) + let crashUploader = LoggerFactory.shared.createCrashUploader( + platformProvider: provider, + remote: remoteTelemetry, + fileStore: fileStore, + logger: logger + ) + + self.platformProvider = provider + self.telemetry = remoteTelemetry + self.crashHandler = crashHandler + self.crashUploader = crashUploader + self.logger = logger + self.lifecycle = lifecycle + } + + public func start() { + lifecycleOperationLock.lock() + guard lifecycle.start() else { + lifecycleOperationLock.unlock() + return + } + + crashHandler.initialize() + lifecycleOperationLock.unlock() + let owner = uploaderOwner + let crashUploader = self.crashUploader + let logger = self.logger + let lifecycle = self.lifecycle + OSCrashUploaderCoordinator.shared.enqueue(owner: owner) { + guard lifecycle.canStartUploader else { + OSCrashUploaderCoordinator.shared.finish(owner: owner) + return + } + crashUploader.start { error in + if let error { + logger.error(message: "LogCrashUploader failed: \(error.localizedDescription)") + } + OSCrashUploaderCoordinator.shared.finish(owner: owner) + } + } } public var kmpVersion: String { @@ -127,7 +290,16 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol { } public func shutdown() { + lifecycleOperationLock.lock() + defer { lifecycleOperationLock.unlock() } + guard lifecycle.beginShutdown() else { + return + } + + OSCrashUploaderCoordinator.shared.cancel(owner: uploaderOwner) + crashHandler.unregister() telemetry.shutdown() + lifecycle.finishShutdown() } } @@ -150,6 +322,7 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol { public let kmpVersion = "unavailable" public let crashStoragePath = "unavailable" + public func start() {} public func log(level: String, message: String) {} public func log( level: String, diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OneSignalLogHttpSender.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OneSignalLogHttpSender.swift index 1d3bc5814..e8219a204 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OneSignalLogHttpSender.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OneSignalLogHttpSender.swift @@ -35,6 +35,7 @@ import Foundation final class OneSignalLogHttpSender: ILogHttpSender { private static let requestTimeout: TimeInterval = 10 private static let transportFailureStatusCode: Int32 = -1 + private static let disabledStatusCode: Int32 = -2 private static let maximumDiagnosticBodyLength = 500 private static let defaultSession: URLSession = { let configuration = URLSessionConfiguration.default @@ -51,28 +52,39 @@ final class OneSignalLogHttpSender: ILogHttpSender { init( session: URLSession = OneSignalLogHttpSender.defaultSession, logger: ILogger = IOSLogger(), - isDiagnosticsEnabled: @escaping () -> Bool = { false } + isDiagnosticsEnabled: @escaping () -> Bool = { false }, + executeIfEnabled: @escaping (@escaping () -> Void) -> Bool = { work in + work() + return true + } ) { self.requestSender = { request, completion in session.dataTask(with: request, completionHandler: completion).resume() } self.logger = logger self.isDiagnosticsEnabled = isDiagnosticsEnabled + self.executeIfEnabled = executeIfEnabled } init( requestSender: @escaping RequestSender, logger: ILogger = IOSLogger(), - isDiagnosticsEnabled: @escaping () -> Bool = { false } + isDiagnosticsEnabled: @escaping () -> Bool = { false }, + executeIfEnabled: @escaping (@escaping () -> Void) -> Bool = { work in + work() + return true + } ) { self.requestSender = requestSender self.logger = logger self.isDiagnosticsEnabled = isDiagnosticsEnabled + self.executeIfEnabled = executeIfEnabled } private let requestSender: RequestSender private let logger: ILogger private let isDiagnosticsEnabled: () -> Bool + private let executeIfEnabled: (@escaping () -> Void) -> Bool func send( request: LogHttpRequest, @@ -96,61 +108,98 @@ final class OneSignalLogHttpSender: ILogHttpSender { urlRequest.setValue(request.contentType, forHTTPHeaderField: "Content-Type") request.headers.forEach { urlRequest.setValue($0.value, forHTTPHeaderField: $0.key) } - requestSender(urlRequest) { data, response, error in - if let error = error { - if self.isDiagnosticsEnabled() { - self.logger.warn( - message: "OneSignalLogHttpSender: POST \(request.url) failed: \(error.localizedDescription)" - ) - } - completionHandler( - LogHttpResponse( - success: false, - statusCode: Self.transportFailureStatusCode, - message: error.localizedDescription - ), - nil + let didStart = executeIfEnabled { + self.requestSender(urlRequest) { data, response, error in + self.handleResponse( + data: data, + response: response, + error: error, + request: request, + completionHandler: completionHandler ) - return } + } + if !didStart { + completionHandler( + LogHttpResponse( + success: false, + statusCode: Self.disabledStatusCode, + message: "Remote logging is disabled" + ), + nil + ) + } + } - guard let response = response as? HTTPURLResponse else { - completionHandler( - LogHttpResponse( - success: false, - statusCode: Self.transportFailureStatusCode, - message: "Missing HTTP response" - ), - nil + private func handleResponse( + data: Data?, + response: URLResponse?, + error: Error?, + request: LogHttpRequest, + completionHandler: @escaping (LogHttpResponse?, Error?) -> Void + ) { + if let error = error { + if isDiagnosticsEnabled() { + logger.warn( + message: "OneSignalLogHttpSender: POST \(request.url) failed: \(error.localizedDescription)" ) - return - } - - let success = (200...299).contains(response.statusCode) - let responseBody = data.flatMap { String(data: $0, encoding: .utf8) } - if self.isDiagnosticsEnabled() { - if success { - self.logger.debug( - message: "OneSignalLogHttpSender: POST \(request.url) -> \(response.statusCode) OK " - + "(\(request.body.size)B)" - ) - } else { - self.logger.warn( - message: "OneSignalLogHttpSender: POST \(request.url) -> \(response.statusCode) " - + "(ct=\(request.contentType), \(request.body.size)B) " - + "body=\(responseBody.map(Self.truncatedDiagnosticBody) ?? "nil")" - ) - } } + completionHandler( + LogHttpResponse( + success: false, + statusCode: Self.transportFailureStatusCode, + message: error.localizedDescription + ), + nil + ) + return + } + guard let response = response as? HTTPURLResponse else { completionHandler( LogHttpResponse( - success: success, - statusCode: Int32(response.statusCode), - message: success ? nil : responseBody + success: false, + statusCode: Self.transportFailureStatusCode, + message: "Missing HTTP response" ), nil ) + return + } + + let success = (200...299).contains(response.statusCode) + let responseBody = data.flatMap { String(data: $0, encoding: .utf8) } + logDiagnostic(response: response, request: request, success: success, responseBody: responseBody) + completionHandler( + LogHttpResponse( + success: success, + statusCode: Int32(response.statusCode), + message: success ? nil : responseBody + ), + nil + ) + } + + private func logDiagnostic( + response: HTTPURLResponse, + request: LogHttpRequest, + success: Bool, + responseBody: String? + ) { + guard isDiagnosticsEnabled() else { + return + } + if success { + logger.debug( + message: "OneSignalLogHttpSender: POST \(request.url) -> \(response.statusCode) OK " + + "(\(request.body.size)B)" + ) + } else { + logger.warn( + message: "OneSignalLogHttpSender: POST \(request.url) -> \(response.statusCode) " + + "(ct=\(request.contentType), \(request.body.size)B) " + + "body=\(responseBody.map(Self.truncatedDiagnosticBody) ?? "nil")" + ) } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLogCrashHandlerTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLogCrashHandlerTests.swift new file mode 100644 index 000000000..fa237b7a4 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLogCrashHandlerTests.swift @@ -0,0 +1,399 @@ +/* + 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 Darwin +import Foundation +import OneSignalKMP +@testable import OneSignalOSCore +import XCTest + +final class OSLogCrashHandlerTests: XCTestCase { + private var temporaryDirectory: URL! + + override func setUpWithError() throws { + temporaryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: temporaryDirectory) + } + + func testSynchronouslyPersistsUncaughtException() throws { + let handler = makeCrashHandler() + let exception = NSException( + name: NSExceptionName("TestException"), + reason: "test crash", + userInfo: nil + ) + + handler.handle( + exception: exception, + stackSymbols: ["0 OneSignalCore 0x000000 OneSignalExample + 1"], + resolvedFrames: [ + frame( + "/private/var/containers/Bundle/Application/App/Frameworks/" + + "OneSignalCore.framework/OneSignalCore" + ) + ] + ) + + XCTAssertEqual( + try FileManager.default.contentsOfDirectory(atPath: temporaryDirectory.path) + .filter { $0.hasSuffix(".otlp") } + .count, + 1 + ) + } + + func testIgnoresCrashWithoutOneSignalModule() throws { + let handler = makeCrashHandler() + + handler.handle( + exception: NSException(name: NSExceptionName("HostException"), reason: nil), + stackSymbols: ["0 ExampleApp 0x000000 AppDelegate + 1"], + resolvedFrames: [frame("/private/var/containers/Bundle/Application/App/ExampleApp")] + ) + + XCTAssertTrue(try FileManager.default.contentsOfDirectory(atPath: temporaryDirectory.path).isEmpty) + } + + private func frame(_ imagePath: String, symbol: String? = nil) -> OSResolvedStackFrame { + OSResolvedStackFrame(imagePath: imagePath, symbolName: symbol) + } +} + +final class OSLogCrashAttributionTests: XCTestCase { + func testIgnoresOneSignalSubstringOutsideModuleField() { + XCTAssertFalse( + isOneSignal([frame(appPath, symbol: "OneSignalNotificationCallback")]) + ) + } + func testRecognizesKnownOneSignalModule() { + XCTAssertTrue(isOneSignal([dynamicFrame("OneSignalNotifications")])) + } + func testRecognizesOneSignalKMPModule() { + XCTAssertTrue(isOneSignal([dynamicFrame("OneSignalKMP")])) + } + func testRecognizesOneSignalCallbackBelowHostThrowingFrame() { + XCTAssertTrue( + isOneSignal([ + frame(appPath, symbol: "HostCallback"), + dynamicFrame("OneSignalCore", symbol: "OneSignalCallback") + ]) + ) + } + func testRecognizesFoundationOriginatedOneSignalCrash() { + XCTAssertTrue( + isOneSignal([ + frame("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"), + frame("/usr/lib/libobjc.A.dylib"), + frame("/System/Library/Frameworks/Foundation.framework/Foundation"), + dynamicFrame("OneSignalCore") + ]) + ) + } + func testRecognizesStaticFoundationOriginatedOneSignalCrash() { + XCTAssertTrue( + isOneSignal([ + frame("/System/Library/Frameworks/Foundation.framework/Foundation"), + frame(appPath, symbol: "-[OneSignalUserDefaults saveCodeableDataForKey:withValue:]") + ]) + ) + } + func testRecognizesStaticSwiftOneSignalModule() { + XCTAssertTrue( + isOneSignal([ + frame(appPath, symbol: "_$s13OneSignalUser19OSPropertyOperationC7execute") + ]) + ) + } + func testRecognizesStaticKotlinOneSignalSymbol() { + XCTAssertTrue( + isOneSignal([ + frame(appPath, symbol: "kfun:com.onesignal.logger.LogCrashReporter.saveCrash") + ]) + ) + } + func testRecognizesStaticNotificationProcessingStack() { + XCTAssertTrue( + isOneSignal([ + frame(appPath, symbol: "finishProcessingNotification"), + frame(appPath, symbol: "onesignal_Log") + ]) + ) + } + func testIgnoresGenericOSSymbol() { + XCTAssertFalse(isOneSignal([frame(appPath, symbol: "OSPropertyOperationExecutor")])) + } + + func testIgnoresOneSignalSymbolInSystemImage() { + XCTAssertFalse( + isOneSignal([frame("/usr/lib/libExample.dylib", symbol: "onesignal_Log")]) + ) + } + + func testSkipsSimulatorSystemImage() { + XCTAssertTrue( + isOneSignal([ + frame( + "/Library/Developer/CoreSimulator/Volumes/iOS/RuntimeRoot/" + + "System/Library/Frameworks/Foundation.framework/Foundation", + symbol: "onesignal_Log" + ), + dynamicFrame("OneSignalOSCore") + ]) + ) + } + + func testIgnoresUnresolvedAndEmptyStacks() { + XCTAssertFalse( + isOneSignal([OSResolvedStackFrame(imagePath: nil, symbolName: "onesignal_Log")]) + ) + XCTAssertFalse(isOneSignal([])) + } + + private let appPath = "/private/var/containers/Bundle/Application/App/ExampleApp" + + private func isOneSignal(_ frames: [OSResolvedStackFrame]) -> Bool { + OSLogCrashHandler.isOneSignalAtFault(frames) + } + + private func frame(_ imagePath: String, symbol: String? = nil) -> OSResolvedStackFrame { + OSResolvedStackFrame(imagePath: imagePath, symbolName: symbol) + } + + private func dynamicFrame(_ module: String, symbol: String? = nil) -> OSResolvedStackFrame { + frame( + "/private/var/containers/Bundle/Application/App/Frameworks/" + + "\(module).framework/\(module)", + symbol: symbol + ) + } +} + +extension OSLogCrashHandlerTests { + func testDoesNotReplaceHostSignalHandler() { + let handler = makeCrashHandler() + let originalHandler = Darwin.signal(SIGABRT, osLogCrashTestSignalHandler) + defer { Darwin.signal(SIGABRT, originalHandler) } + + handler.initialize() + defer { handler.unregister() } + let installedHandler = Darwin.signal(SIGABRT, osLogCrashTestSignalHandler) + Darwin.signal(SIGABRT, installedHandler) + + XCTAssertEqual( + signalHandlerAddress(installedHandler), + signalHandlerAddress(osLogCrashTestSignalHandler) + ) + } + + func testRestoresPreviousExceptionHandler() { + let handler = makeCrashHandler() + let originalHandler = NSGetUncaughtExceptionHandler() + NSSetUncaughtExceptionHandler(osLogCrashTestExceptionHandler) + defer { NSSetUncaughtExceptionHandler(originalHandler) } + + handler.initialize() + handler.unregister() + + XCTAssertEqual( + exceptionHandlerAddress(NSGetUncaughtExceptionHandler()), + exceptionHandlerAddress(osLogCrashTestExceptionHandler) + ) + } + + func testPreservesHandlerInstalledAfterIt() { + let handler = makeCrashHandler() + let originalHandler = NSGetUncaughtExceptionHandler() + defer { NSSetUncaughtExceptionHandler(originalHandler) } + handler.initialize() + + NSSetUncaughtExceptionHandler(osLogCrashReplacementExceptionHandler) + handler.unregister() + + XCTAssertEqual( + exceptionHandlerAddress(NSGetUncaughtExceptionHandler()), + exceptionHandlerAddress(osLogCrashReplacementExceptionHandler) + ) + } + + func testForwardsInFlightCallbackAfterUnregister() { + resetExceptionHandlerCallCount() + let handler = makeCrashHandler() + let originalHandler = NSGetUncaughtExceptionHandler() + NSSetUncaughtExceptionHandler(osLogCrashCountingExceptionHandler) + defer { NSSetUncaughtExceptionHandler(originalHandler) } + handler.initialize() + handler.unregister() + + OSLogCrashHandler.handleActive( + NSException(name: NSExceptionName("InFlightException"), reason: nil) + ) + + XCTAssertEqual(exceptionHandlerCallCount(), 1) + } + + func testStopsReentrantPreviousHandler() { + resetExceptionHandlerCallCount() + let handler = makeCrashHandler() + let originalHandler = NSGetUncaughtExceptionHandler() + NSSetUncaughtExceptionHandler(osLogCrashReentrantExceptionHandler) + defer { NSSetUncaughtExceptionHandler(originalHandler) } + handler.initialize() + defer { handler.unregister() } + + OSLogCrashHandler.handleActive( + NSException(name: NSExceptionName("ReentrantException"), reason: nil) + ) + + XCTAssertEqual(exceptionHandlerCallCount(), 1) + } + + func testUploaderCoordinatorSerializesUploaders() { + let coordinator = OSCrashUploaderCoordinator() + let firstOwner = UUID() + let secondOwner = UUID() + var started: [String] = [] + + coordinator.enqueue(owner: firstOwner) { + started.append("first") + } + coordinator.enqueue(owner: secondOwner) { + started.append("second") + } + + XCTAssertEqual(started, ["first"]) + coordinator.finish(owner: firstOwner) + XCTAssertEqual(started, ["first", "second"]) + } + + func testUploaderCoordinatorCancelsPendingUploader() { + let coordinator = OSCrashUploaderCoordinator() + let firstOwner = UUID() + let secondOwner = UUID() + var started: [String] = [] + + coordinator.enqueue(owner: firstOwner) { + started.append("first") + } + coordinator.enqueue(owner: secondOwner) { + started.append("second") + } + coordinator.cancel(owner: secondOwner) + coordinator.finish(owner: firstOwner) + + XCTAssertEqual(started, ["first"]) + } + + func testUploaderCoordinatorCancelActiveWaitsForFinishBeforeStartingNext() { + let coordinator = OSCrashUploaderCoordinator() + let firstOwner = UUID() + let secondOwner = UUID() + var started: [String] = [] + + coordinator.enqueue(owner: firstOwner) { + started.append("first") + } + coordinator.enqueue(owner: secondOwner) { + started.append("second") + } + coordinator.cancel(owner: firstOwner) + + XCTAssertEqual(started, ["first"]) + coordinator.finish(owner: firstOwner) + + XCTAssertEqual(started, ["first", "second"]) + } + + private func makeCrashHandler() -> OSLogCrashHandler { + let store = FileLogStore(rootPath: temporaryDirectory.path) + let telemetry = LoggerFactory.shared.createCrashLocalTelemetry( + platformProvider: makePlatformProvider(), + fileStore: store + ) + let reporter = LoggerFactory.shared.createCrashReporter( + crashTelemetry: telemetry, + logger: OSCrashLogger() + ) + return OSLogCrashHandler(reporter: reporter) + } + + private func makePlatformProvider() -> OSLoggerPlatformProvider { + OSLoggerPlatformProvider( + installIdProvider: { "install-id" }, + onesignalIdProvider: { "onesignal-id" }, + pushSubscriptionIdProvider: { "subscription-id" }, + appStateProvider: { "foreground" }, + featureFlagsProvider: { ["feature"] }, + remoteLogLevelProvider: { "warn" }, + exporterLoggingEnabledProvider: { true } + ) + } + +} + +private typealias TestSignalHandler = @convention(c) (Int32) -> Void +private typealias TestExceptionHandler = @convention(c) (NSException) -> Void +private let exceptionHandlerCallLock = NSLock() +private var exceptionHandlerCalls = 0 + +private func osLogCrashTestSignalHandler(_: Int32) {} +private func osLogCrashTestExceptionHandler(_: NSException) {} +private func osLogCrashReplacementExceptionHandler(_: NSException) {} +private func osLogCrashCountingExceptionHandler(_: NSException) { + exceptionHandlerCallLock.lock() + exceptionHandlerCalls += 1 + exceptionHandlerCallLock.unlock() +} + +private func osLogCrashReentrantExceptionHandler(_ exception: NSException) { + osLogCrashCountingExceptionHandler(exception) + OSLogCrashHandler.handleActive(exception) +} + +private func resetExceptionHandlerCallCount() { + exceptionHandlerCallLock.lock() + exceptionHandlerCalls = 0 + exceptionHandlerCallLock.unlock() +} + +private func exceptionHandlerCallCount() -> Int { + exceptionHandlerCallLock.lock() + defer { exceptionHandlerCallLock.unlock() } + return exceptionHandlerCalls +} + +private func signalHandlerAddress(_ handler: TestSignalHandler?) -> UInt { + handler.map { unsafeBitCast($0, to: UInt.self) } ?? 0 +} + +private func exceptionHandlerAddress(_ handler: TestExceptionHandler?) -> UInt { + handler.map { unsafeBitCast($0, to: UInt.self) } ?? 0 +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLoggerAdaptersTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLoggerAdaptersTests.swift index 9c856d1e0..4d205efc5 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLoggerAdaptersTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLoggerAdaptersTests.swift @@ -166,6 +166,32 @@ final class OSLoggerAdaptersTests: XCTestCase { wait(for: [sent], timeout: 2) } + func testHttpSenderDoesNotStartRequestWhenDisabled() { + var requestStarted = false + let sender = OneSignalLogHttpSender( + requestSender: { _, _ in requestStarted = true }, + executeIfEnabled: { _ in false } + ) + let request = LogHttpRequest( + url: "https://example.com/sdk/log", + headers: [:], + contentType: "application/x-protobuf", + body: makeKotlinBytes([1]) + ) + let sent = expectation(description: "returns disabled response") + + sender.send(request: request) { response, error in + XCTAssertNil(error) + XCTAssertFalse(response?.success == true) + XCTAssertEqual(response?.statusCode, -2) + XCTAssertEqual(response?.message, "Remote logging is disabled") + sent.fulfill() + } + + wait(for: [sent], timeout: 2) + XCTAssertFalse(requestStarted) + } + func testLoggerDelegatesToOneSignalLog() { let listener = LoggerAdapterListener() OneSignalLog.debug().__add(listener) diff --git a/iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingController.swift b/iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingController.swift index 6cdea28b9..55e452656 100644 --- a/iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingController.swift +++ b/iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingController.swift @@ -295,6 +295,7 @@ final class OSRemoteLoggingController: NSObject, OSInternalLogSink { return } + newRemoteLogger.start() logStartupDiagnostic(remoteLogger: newRemoteLogger) stateQueue.sync { guard self.remoteLogger === newRemoteLogger, diff --git a/iOS_SDK/OneSignalSDK/UnitTests/OSRemoteLoggingControllerTests.swift b/iOS_SDK/OneSignalSDK/UnitTests/OSRemoteLoggingControllerTests.swift index dac5c59aa..523cc5394 100644 --- a/iOS_SDK/OneSignalSDK/UnitTests/OSRemoteLoggingControllerTests.swift +++ b/iOS_SDK/OneSignalSDK/UnitTests/OSRemoteLoggingControllerTests.swift @@ -193,6 +193,27 @@ final class OSRemoteLoggingControllerTests: XCTestCase { wait(for: [constructed, configured], timeout: 2) } + func testStartsOnlyLoggerThatWinsReentrantConfiguration() { + var controller: OSRemoteLoggingController! + var loggers: [RemoteTelemetrySpy] = [] + var didReenter = false + controller = makeController { _ in + let logger = RemoteTelemetrySpy() + loggers.append(logger) + if !didReenter { + didReenter = true + controller.configure(remoteParams: Self.remoteParams(level: "ERROR")) + } + return logger + } + + controller.configure(remoteParams: Self.remoteParams(level: "ERROR")) + + XCTAssertEqual(loggers.count, 2) + XCTAssertEqual(loggers.map(\.startCount).reduce(0, +), 1) + XCTAssertEqual(loggers.map(\.shutdownCount).reduce(0, +), 1) + } + func testStartupDiagnosticCanResetControllerWithoutDeadlock() { let reset = expectation(description: "resets from startup diagnostic listener") let telemetry = RemoteTelemetrySpy() @@ -260,8 +281,15 @@ private final class RemoteTelemetrySpy: OSStructuredRemoteLoggerProtocol { private(set) var exceptionTypes: [String?] = [] private(set) var exceptionMessages: [String?] = [] private(set) var exceptionStacktraces: [String?] = [] + private(set) var startCount = 0 private(set) var shutdownCount = 0 + func start() { + lock.lock() + startCount += 1 + lock.unlock() + } + func log(level: String, message: String) { log( level: level,