From b9171dec37602afc75433212016ff90720be72cc Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 11 Aug 2026 16:48:08 -0700 Subject: [PATCH 1/7] feat(logging): add OSLogCrashHandler for fatal crash capture --- .../OneSignal.xcodeproj/project.pbxproj | 4 + .../Source/Logging/OSLogCrashHandler.swift | 222 ++++++++++++++++++ .../Source/Logging/OSRemoteLogger.swift | 34 ++- .../OSLoggerAdaptersTests.swift | 51 ++++ 4 files changed, 309 insertions(+), 2 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSLogCrashHandler.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 078da8380..391b0f413 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -80,6 +80,7 @@ 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 */; }; + 497800000000000000000002 /* OSLogCrashHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 497800000000000000000001 /* 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 */; }; @@ -1800,6 +1801,7 @@ 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 = ""; }; + 497800000000000000000001 /* 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 +2297,7 @@ 3C14E3AB2FAE54C006ED053 /* FileLogStore.swift */, 3C14E3AA2FAE54C006ED053 /* OneSignalLogHttpSender.swift */, 3C14E3A92FAE54C006ED053 /* IOSLogger.swift */, + 497800000000000000000001 /* OSLogCrashHandler.swift */, 3C14E3AC2FAE54C006ED053 /* OSLoggerPlatformProvider.swift */, 6972EE491A57C79EFE56D4C8 /* OSRemoteLogger.swift */, ); @@ -4442,6 +4445,7 @@ 3C14E3B12FAE54C006ED053 /* OneSignalLogHttpSender.swift in Sources */, 3C4F9E4428A4466C009F453A /* OSOperationRepo.swift in Sources */, 3C14E3B02FAE54C006ED053 /* IOSLogger.swift in Sources */, + 497800000000000000000002 /* OSLogCrashHandler.swift in Sources */, 7732574D325D34CC7C498199 /* OSRemoteLogger.swift in Sources */, 3C11518B289ADEEB00565C41 /* OSEventProducer.swift in Sources */, 3C115165289A259500565C41 /* OneSignalOSCore.docc in Sources */, 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..73e6e6e8f --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSLogCrashHandler.swift @@ -0,0 +1,222 @@ +/* + 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 +import OneSignalCore +@_implementationOnly import OneSignalKMP + +private typealias OSSignalHandler = @convention(c) (Int32) -> Void +private typealias OSExceptionHandler = @convention(c) (NSException) -> Void + +private func osLogUncaughtExceptionHandler(_ exception: NSException) { + OSLogCrashHandler.active?.handle(exception: exception) +} + +private func osLogSignalHandler(_ signalNumber: Int32) { + OSLogCrashHandler.active?.handle(signalNumber: signalNumber) +} + +/// Captures native fatal failures and persists them through the synchronous KMP +/// crash reporter before forwarding to the handler that was previously installed. +final class OSLogCrashHandler: ILogCrashHandler { + fileprivate static var active: OSLogCrashHandler? + + private static let handledSignals = [ + SIGABRT, + SIGILL, + SIGSEGV, + SIGFPE, + SIGBUS, + SIGPIPE, + SIGTRAP + ] + + private let reporter: ILogCrashReporter + private var previousExceptionHandler: OSExceptionHandler? + private var previousSignalHandlers: [Int32: OSSignalHandler] = [:] + private var isInitialized = false + private var didCaptureFatal = false + + init(reporter: ILogCrashReporter) { + self.reporter = reporter + } + + func initialize() { + guard !isInitialized else { + return + } + + previousExceptionHandler = NSGetUncaughtExceptionHandler() + NSSetUncaughtExceptionHandler(osLogUncaughtExceptionHandler) + for signalNumber in Self.handledSignals { + if let previousHandler = Darwin.signal(signalNumber, osLogSignalHandler) { + let previousAddress = Self.signalHandlerAddress(previousHandler) + if previousAddress == Self.signalHandlerAddress(SIG_ERR) { + continue + } + if previousAddress == Self.signalHandlerAddress(SIG_IGN) { + Darwin.signal(signalNumber, previousHandler) + continue + } + previousSignalHandlers[signalNumber] = previousHandler + } + } + Self.active = self + isInitialized = true + } + + func unregister() { + guard isInitialized else { + return + } + + if Self.active === self { + Self.active = nil + } + if Self.exceptionHandlerAddress(NSGetUncaughtExceptionHandler()) + == Self.exceptionHandlerAddress(osLogUncaughtExceptionHandler) { + NSSetUncaughtExceptionHandler(previousExceptionHandler) + } + for (signalNumber, previousHandler) in previousSignalHandlers { + guard let currentHandler = Darwin.signal(signalNumber, previousHandler) else { + continue + } + if Self.signalHandlerAddress(currentHandler) + != Self.signalHandlerAddress(osLogSignalHandler) { + Darwin.signal(signalNumber, currentHandler) + } + } + previousSignalHandlers.removeAll() + previousExceptionHandler = nil + isInitialized = false + didCaptureFatal = false + } + + func handle(exception: NSException) { + handle(exception: exception, stackSymbols: exception.callStackSymbols) + } + + func handle(exception: NSException, stackSymbols: [String]) { + guard Self.isOneSignalAtFault(stackSymbols) else { + previousExceptionHandler?(exception) + return + } + capture( + exceptionType: exception.name.rawValue, + exceptionMessage: exception.reason ?? exception.description, + stacktrace: stackSymbols.joined(separator: "\n") + ) + previousExceptionHandler?(exception) + } + + fileprivate func handle(signalNumber: Int32) { + // Swift, Foundation, and Kotlin/Native are not async-signal-safe after an + // arbitrary memory fault. Persisting here is necessarily best effort. + // An uncaught NSException normally terminates with SIGABRT after its + // exception handler runs. Avoid recording the same fatal failure twice. + let stackSymbols = Thread.callStackSymbols + if !didCaptureFatal && Self.isOneSignalAtFault(stackSymbols) { + let signalDescription = String(cString: strsignal(signalNumber)) + capture( + exceptionType: "Signal \(signalNumber)", + exceptionMessage: signalDescription, + stacktrace: stackSymbols.joined(separator: "\n") + ) + } + + let previousHandler = previousSignalHandlers[signalNumber] ?? SIG_DFL! + Darwin.signal(signalNumber, previousHandler) + if Self.isCustomSignalHandler(previousHandler) { + previousHandler(signalNumber) + } else if Self.signalHandlerAddress(previousHandler) != Self.signalHandlerAddress(SIG_IGN) { + Darwin.raise(signalNumber) + } + } + + private func capture( + exceptionType: String, + exceptionMessage: String, + stacktrace: String + ) { + didCaptureFatal = true + let crash = CrashData( + threadName: Self.currentThreadName, + exceptionType: exceptionType, + exceptionMessage: exceptionMessage, + stacktrace: stacktrace + ) + do { + _ = try reporter.saveCrash(crash: crash) + } catch { + OneSignalLog.onesignalLog( + .LL_ERROR, + message: "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 isCustomSignalHandler(_ handler: OSSignalHandler) -> Bool { + let address = signalHandlerAddress(handler) + return address != signalHandlerAddress(SIG_DFL!) + && address != signalHandlerAddress(SIG_IGN) + && address != signalHandlerAddress(SIG_ERR) + && address != signalHandlerAddress(osLogSignalHandler) + } + + private static func signalHandlerAddress(_ handler: OSSignalHandler) -> UInt { + unsafeBitCast(handler, to: UInt.self) + } + + private static func exceptionHandlerAddress(_ handler: OSExceptionHandler?) -> UInt { + handler.map { unsafeBitCast($0, to: UInt.self) } ?? 0 + } + + static func isOneSignalAtFault(_ stackSymbols: [String]) -> Bool { + stackSymbols.contains { $0.localizedCaseInsensitiveContains("OneSignal") } + } +} + +#endif diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSRemoteLogger.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSRemoteLogger.swift index e8cd3d7e0..3a0771a62 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSRemoteLogger.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSRemoteLogger.swift @@ -56,6 +56,8 @@ public protocol OSStructuredRemoteLoggerProtocol: OSRemoteLoggerProtocol { public final class OSRemoteLogger: OSRemoteLoggerProtocol { private let telemetry: ILogTelemetryRemote private let platformProvider: OSLoggerPlatformProvider + private let crashHandler: ILogCrashHandler + private let crashUploader: LogCrashUploader public init( installIdProvider: @escaping () -> String, @@ -76,14 +78,41 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol { exporterLoggingEnabledProvider: exporterLoggingEnabledProvider ) let logger = IOSLogger() - self.platformProvider = provider - self.telemetry = LoggerFactory.shared.createRemoteTelemetry( + let fileStore = FileLogStore(rootPath: provider.crashStoragePath) + let remoteTelemetry = LoggerFactory.shared.createRemoteTelemetry( platformProvider: provider, httpSender: OneSignalLogHttpSender( logger: logger, isDiagnosticsEnabled: exporterLoggingEnabledProvider ) ) + let crashTelemetry = LoggerFactory.shared.createCrashLocalTelemetry( + platformProvider: provider, + fileStore: fileStore + ) + let crashReporter = LoggerFactory.shared.createCrashReporter( + crashTelemetry: crashTelemetry, + logger: logger + ) + 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 + + crashHandler.initialize() + crashUploader.start { error in + if let error { + logger.error(message: "LogCrashUploader failed: \(error.localizedDescription)") + } + } } public var kmpVersion: String { @@ -127,6 +156,7 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol { } public func shutdown() { + crashHandler.unregister() telemetry.shutdown() } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLoggerAdaptersTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLoggerAdaptersTests.swift index 9c856d1e0..110fdcd1f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLoggerAdaptersTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLoggerAdaptersTests.swift @@ -212,6 +212,57 @@ final class OSLoggerAdaptersTests: XCTestCase { ) } + func testCrashHandlerSynchronouslyPersistsUncaughtException() throws { + let store = FileLogStore(rootPath: temporaryDirectory.path) + let logger = IOSLogger() + let telemetry = LoggerFactory.shared.createCrashLocalTelemetry( + platformProvider: makePlatformProvider(), + fileStore: store + ) + let reporter = LoggerFactory.shared.createCrashReporter( + crashTelemetry: telemetry, + logger: logger + ) + let handler = OSLogCrashHandler(reporter: reporter) + let exception = NSException( + name: NSExceptionName("TestException"), + reason: "test crash", + userInfo: nil + ) + + handler.handle( + exception: exception, + stackSymbols: ["0 OneSignalCore 0x000000 OneSignalExample + 1"] + ) + + XCTAssertEqual( + try FileManager.default.contentsOfDirectory(atPath: temporaryDirectory.path) + .filter { $0.hasSuffix(".otlp") } + .count, + 1 + ) + } + + func testCrashHandlerIgnoresCrashWithoutOneSignalFrames() throws { + let store = FileLogStore(rootPath: temporaryDirectory.path) + let telemetry = LoggerFactory.shared.createCrashLocalTelemetry( + platformProvider: makePlatformProvider(), + fileStore: store + ) + let reporter = LoggerFactory.shared.createCrashReporter( + crashTelemetry: telemetry, + logger: IOSLogger() + ) + let handler = OSLogCrashHandler(reporter: reporter) + + handler.handle( + exception: NSException(name: NSExceptionName("HostException"), reason: nil), + stackSymbols: ["0 ExampleApp 0x000000 AppDelegate + 1"] + ) + + XCTAssertTrue(try FileManager.default.contentsOfDirectory(atPath: temporaryDirectory.path).isEmpty) + } + func testPlatformProviderReturnsInjectedIdentifiersAndPlatformMetadata() { let provider = makePlatformProvider() var firstInstallId: String? From e760469b3c9f41af6badb5035749c799cd703367 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 11 Aug 2026 18:01:09 -0700 Subject: [PATCH 2/7] fix(logging): harden crash handler and logger lifecycle --- .../Source/Logging/OSLogCrashHandler.swift | 131 ++++-------- .../Source/Logging/OSRemoteLogger.swift | 120 ++++++++++- .../Logging/OneSignalLogHttpSender.swift | 20 +- .../OSLoggerAdaptersTests.swift | 201 ++++++++++++++++++ .../Source/OSRemoteLoggingController.swift | 1 + .../OSRemoteLoggingControllerTests.swift | 28 +++ 6 files changed, 407 insertions(+), 94 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSLogCrashHandler.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSLogCrashHandler.swift index 73e6e6e8f..e042f4765 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSLogCrashHandler.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSLogCrashHandler.swift @@ -33,91 +33,64 @@ import Foundation import OneSignalCore @_implementationOnly import OneSignalKMP -private typealias OSSignalHandler = @convention(c) (Int32) -> Void private typealias OSExceptionHandler = @convention(c) (NSException) -> Void private func osLogUncaughtExceptionHandler(_ exception: NSException) { - OSLogCrashHandler.active?.handle(exception: exception) + OSLogCrashHandler.handleActive(exception) } -private func osLogSignalHandler(_ signalNumber: Int32) { - OSLogCrashHandler.active?.handle(signalNumber: signalNumber) -} - -/// Captures native fatal failures and persists them through the synchronous KMP -/// crash reporter before forwarding to the handler that was previously installed. +/// 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 { - fileprivate static var active: OSLogCrashHandler? - - private static let handledSignals = [ - SIGABRT, - SIGILL, - SIGSEGV, - SIGFPE, - SIGBUS, - SIGPIPE, - SIGTRAP - ] + 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 previousSignalHandlers: [Int32: OSSignalHandler] = [:] private var isInitialized = false - private var didCaptureFatal = 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() - NSSetUncaughtExceptionHandler(osLogUncaughtExceptionHandler) - for signalNumber in Self.handledSignals { - if let previousHandler = Darwin.signal(signalNumber, osLogSignalHandler) { - let previousAddress = Self.signalHandlerAddress(previousHandler) - if previousAddress == Self.signalHandlerAddress(SIG_ERR) { - continue - } - if previousAddress == Self.signalHandlerAddress(SIG_IGN) { - Darwin.signal(signalNumber, previousHandler) - continue - } - previousSignalHandlers[signalNumber] = previousHandler - } - } + Self.inactivePreviousHandler = previousExceptionHandler Self.active = self + NSSetUncaughtExceptionHandler(osLogUncaughtExceptionHandler) isInitialized = true } func unregister() { + Self.registryLock.lock() + defer { Self.registryLock.unlock() } guard isInitialized else { return } - if Self.active === self { - Self.active = nil - } - if Self.exceptionHandlerAddress(NSGetUncaughtExceptionHandler()) - == Self.exceptionHandlerAddress(osLogUncaughtExceptionHandler) { + let isCurrentHandler = Self.exceptionHandlerAddress(NSGetUncaughtExceptionHandler()) + == Self.exceptionHandlerAddress(osLogUncaughtExceptionHandler) + if Self.active === self, isCurrentHandler { NSSetUncaughtExceptionHandler(previousExceptionHandler) } - for (signalNumber, previousHandler) in previousSignalHandlers { - guard let currentHandler = Darwin.signal(signalNumber, previousHandler) else { - continue - } - if Self.signalHandlerAddress(currentHandler) - != Self.signalHandlerAddress(osLogSignalHandler) { - Darwin.signal(signalNumber, currentHandler) - } + if Self.active === self { + Self.active = nil } - previousSignalHandlers.removeAll() - previousExceptionHandler = nil isInitialized = false - didCaptureFatal = false } func handle(exception: NSException) { @@ -137,36 +110,11 @@ final class OSLogCrashHandler: ILogCrashHandler { previousExceptionHandler?(exception) } - fileprivate func handle(signalNumber: Int32) { - // Swift, Foundation, and Kotlin/Native are not async-signal-safe after an - // arbitrary memory fault. Persisting here is necessarily best effort. - // An uncaught NSException normally terminates with SIGABRT after its - // exception handler runs. Avoid recording the same fatal failure twice. - let stackSymbols = Thread.callStackSymbols - if !didCaptureFatal && Self.isOneSignalAtFault(stackSymbols) { - let signalDescription = String(cString: strsignal(signalNumber)) - capture( - exceptionType: "Signal \(signalNumber)", - exceptionMessage: signalDescription, - stacktrace: stackSymbols.joined(separator: "\n") - ) - } - - let previousHandler = previousSignalHandlers[signalNumber] ?? SIG_DFL! - Darwin.signal(signalNumber, previousHandler) - if Self.isCustomSignalHandler(previousHandler) { - previousHandler(signalNumber) - } else if Self.signalHandlerAddress(previousHandler) != Self.signalHandlerAddress(SIG_IGN) { - Darwin.raise(signalNumber) - } - } - private func capture( exceptionType: String, exceptionMessage: String, stacktrace: String ) { - didCaptureFatal = true let crash = CrashData( threadName: Self.currentThreadName, exceptionType: exceptionType, @@ -198,22 +146,29 @@ final class OSLogCrashHandler: ILogCrashHandler { return threadName.isEmpty ? "unknown" : threadName } - private static func isCustomSignalHandler(_ handler: OSSignalHandler) -> Bool { - let address = signalHandlerAddress(handler) - return address != signalHandlerAddress(SIG_DFL!) - && address != signalHandlerAddress(SIG_IGN) - && address != signalHandlerAddress(SIG_ERR) - && address != signalHandlerAddress(osLogSignalHandler) - } - - private static func signalHandlerAddress(_ handler: OSSignalHandler) -> UInt { - unsafeBitCast(handler, to: UInt.self) - } - 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(_ stackSymbols: [String]) -> Bool { stackSymbols.contains { $0.localizedCaseInsensitiveContains("OneSignal") } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSRemoteLogger.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSRemoteLogger.swift index 3a0771a62..0b2d642c7 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSRemoteLogger.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSRemoteLogger.swift @@ -31,6 +31,7 @@ public protocol OSRemoteLoggerProtocol: AnyObject { var kmpVersion: String { get } var crashStoragePath: String { get } + func start() func log(level: String, message: String) func forceFlush(completion: @escaping () -> Void) func shutdown() @@ -51,6 +52,87 @@ public protocol OSStructuredRemoteLoggerProtocol: OSRemoteLoggerProtocol { @_implementationOnly import OneSignalKMP +private final class OSRemoteLoggerLifecycle { + private let lock = NSLock() + private var isStarted = false + private var isShutdown = false + + var isActive: Bool { + lock.lock() + defer { lock.unlock() } + return isStarted && !isShutdown + } + + func start() -> Bool { + lock.lock() + defer { lock.unlock() } + guard !isStarted, !isShutdown else { + return false + } + isStarted = true + return true + } + + func shutdown() -> Bool { + lock.lock() + defer { lock.unlock() } + guard !isShutdown else { + return false + } + isShutdown = true + return true + } +} + +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 { @@ -58,6 +140,10 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol { 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, @@ -78,12 +164,14 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol { exporterLoggingEnabledProvider: exporterLoggingEnabledProvider ) let logger = IOSLogger() + let lifecycle = OSRemoteLoggerLifecycle() let fileStore = FileLogStore(rootPath: provider.crashStoragePath) let remoteTelemetry = LoggerFactory.shared.createRemoteTelemetry( platformProvider: provider, httpSender: OneSignalLogHttpSender( logger: logger, - isDiagnosticsEnabled: exporterLoggingEnabledProvider + isDiagnosticsEnabled: exporterLoggingEnabledProvider, + isEnabled: { lifecycle.isActive } ) ) let crashTelemetry = LoggerFactory.shared.createCrashLocalTelemetry( @@ -106,11 +194,27 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol { self.telemetry = remoteTelemetry self.crashHandler = crashHandler self.crashUploader = crashUploader + self.logger = logger + self.lifecycle = lifecycle + } + + public func start() { + lifecycleOperationLock.lock() + defer { lifecycleOperationLock.unlock() } + guard lifecycle.start() else { + return + } crashHandler.initialize() - crashUploader.start { error in - if let error { - logger.error(message: "LogCrashUploader failed: \(error.localizedDescription)") + let owner = uploaderOwner + let crashUploader = self.crashUploader + let logger = self.logger + OSCrashUploaderCoordinator.shared.enqueue(owner: owner) { + crashUploader.start { error in + if let error { + logger.error(message: "LogCrashUploader failed: \(error.localizedDescription)") + } + OSCrashUploaderCoordinator.shared.finish(owner: owner) } } } @@ -156,6 +260,13 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol { } public func shutdown() { + lifecycleOperationLock.lock() + defer { lifecycleOperationLock.unlock() } + guard lifecycle.shutdown() else { + return + } + + OSCrashUploaderCoordinator.shared.cancel(owner: uploaderOwner) crashHandler.unregister() telemetry.shutdown() } @@ -180,6 +291,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..0373b2caa 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OneSignalLogHttpSender.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OneSignalLogHttpSender.swift @@ -51,33 +51,49 @@ final class OneSignalLogHttpSender: ILogHttpSender { init( session: URLSession = OneSignalLogHttpSender.defaultSession, logger: ILogger = IOSLogger(), - isDiagnosticsEnabled: @escaping () -> Bool = { false } + isDiagnosticsEnabled: @escaping () -> Bool = { false }, + isEnabled: @escaping () -> Bool = { true } ) { self.requestSender = { request, completion in session.dataTask(with: request, completionHandler: completion).resume() } self.logger = logger self.isDiagnosticsEnabled = isDiagnosticsEnabled + self.isEnabled = isEnabled } init( requestSender: @escaping RequestSender, logger: ILogger = IOSLogger(), - isDiagnosticsEnabled: @escaping () -> Bool = { false } + isDiagnosticsEnabled: @escaping () -> Bool = { false }, + isEnabled: @escaping () -> Bool = { true } ) { self.requestSender = requestSender self.logger = logger self.isDiagnosticsEnabled = isDiagnosticsEnabled + self.isEnabled = isEnabled } private let requestSender: RequestSender private let logger: ILogger private let isDiagnosticsEnabled: () -> Bool + private let isEnabled: () -> Bool func send( request: LogHttpRequest, completionHandler: @escaping (LogHttpResponse?, Error?) -> Void ) { + guard isEnabled() else { + completionHandler( + LogHttpResponse( + success: false, + statusCode: Self.transportFailureStatusCode, + message: "Remote logging is disabled" + ), + nil + ) + return + } guard let url = URL(string: request.url) else { completionHandler( LogHttpResponse( diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLoggerAdaptersTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLoggerAdaptersTests.swift index 110fdcd1f..00260b59f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLoggerAdaptersTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLoggerAdaptersTests.swift @@ -25,6 +25,7 @@ THE SOFTWARE. */ +import Darwin import Foundation import OneSignalCore import OneSignalKMP @@ -166,6 +167,31 @@ final class OSLoggerAdaptersTests: XCTestCase { wait(for: [sent], timeout: 2) } + func testHttpSenderDoesNotStartRequestWhenDisabled() { + var requestStarted = false + let sender = OneSignalLogHttpSender( + requestSender: { _, _ in requestStarted = true }, + isEnabled: { 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?.message, "Remote logging is disabled") + sent.fulfill() + } + + wait(for: [sent], timeout: 2) + XCTAssertFalse(requestStarted) + } + func testLoggerDelegatesToOneSignalLog() { let listener = LoggerAdapterListener() OneSignalLog.debug().__add(listener) @@ -263,6 +289,129 @@ final class OSLoggerAdaptersTests: XCTestCase { XCTAssertTrue(try FileManager.default.contentsOfDirectory(atPath: temporaryDirectory.path).isEmpty) } + func testCrashHandlerDoesNotReplaceHostSignalHandler() { + let store = FileLogStore(rootPath: temporaryDirectory.path) + let telemetry = LoggerFactory.shared.createCrashLocalTelemetry( + platformProvider: makePlatformProvider(), + fileStore: store + ) + let reporter = LoggerFactory.shared.createCrashReporter( + crashTelemetry: telemetry, + logger: IOSLogger() + ) + let handler = OSLogCrashHandler(reporter: reporter) + let originalHandler = Darwin.signal(SIGABRT, osLoggerAdaptersTestSignalHandler) + defer { Darwin.signal(SIGABRT, originalHandler) } + + handler.initialize() + defer { handler.unregister() } + let installedHandler = Darwin.signal(SIGABRT, osLoggerAdaptersTestSignalHandler) + Darwin.signal(SIGABRT, installedHandler) + + XCTAssertEqual( + signalHandlerAddress(installedHandler), + signalHandlerAddress(osLoggerAdaptersTestSignalHandler) + ) + } + + func testCrashHandlerRestoresPreviousExceptionHandler() { + let handler = makeCrashHandler() + let originalHandler = NSGetUncaughtExceptionHandler() + NSSetUncaughtExceptionHandler(osLoggerAdaptersTestExceptionHandler) + defer { NSSetUncaughtExceptionHandler(originalHandler) } + + handler.initialize() + handler.unregister() + + XCTAssertEqual( + exceptionHandlerAddress(NSGetUncaughtExceptionHandler()), + exceptionHandlerAddress(osLoggerAdaptersTestExceptionHandler) + ) + } + + func testCrashHandlerPreservesHandlerInstalledAfterIt() { + let handler = makeCrashHandler() + let originalHandler = NSGetUncaughtExceptionHandler() + defer { NSSetUncaughtExceptionHandler(originalHandler) } + handler.initialize() + + NSSetUncaughtExceptionHandler(osLoggerAdaptersReplacementExceptionHandler) + handler.unregister() + + XCTAssertEqual( + exceptionHandlerAddress(NSGetUncaughtExceptionHandler()), + exceptionHandlerAddress(osLoggerAdaptersReplacementExceptionHandler) + ) + } + + func testCrashHandlerForwardsInFlightCallbackAfterUnregister() { + resetExceptionHandlerCallCount() + let handler = makeCrashHandler() + let originalHandler = NSGetUncaughtExceptionHandler() + NSSetUncaughtExceptionHandler(osLoggerAdaptersCountingExceptionHandler) + defer { NSSetUncaughtExceptionHandler(originalHandler) } + handler.initialize() + handler.unregister() + + OSLogCrashHandler.handleActive( + NSException(name: NSExceptionName("InFlightException"), reason: nil) + ) + + XCTAssertEqual(exceptionHandlerCallCount(), 1) + } + + func testCrashHandlerStopsReentrantPreviousHandler() { + resetExceptionHandlerCallCount() + let handler = makeCrashHandler() + let originalHandler = NSGetUncaughtExceptionHandler() + NSSetUncaughtExceptionHandler(osLoggerAdaptersReentrantExceptionHandler) + defer { NSSetUncaughtExceptionHandler(originalHandler) } + handler.initialize() + defer { handler.unregister() } + + OSLogCrashHandler.handleActive( + NSException(name: NSExceptionName("ReentrantException"), reason: nil) + ) + + XCTAssertEqual(exceptionHandlerCallCount(), 1) + } + + func testCrashUploaderCoordinatorSerializesUploaders() { + 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 testCrashUploaderCoordinatorCancelsPendingUploader() { + 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 testPlatformProviderReturnsInjectedIdentifiersAndPlatformMetadata() { let provider = makePlatformProvider() var firstInstallId: String? @@ -314,11 +463,63 @@ final class OSLoggerAdaptersTests: XCTestCase { ) } + 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: IOSLogger() + ) + return OSLogCrashHandler(reporter: reporter) + } + private func makeKotlinBytes(_ bytes: [UInt8]) -> KotlinByteArray { AppleByteArrayInterop.shared.toByteArray(data: Data(bytes)) } } +private typealias TestSignalHandler = @convention(c) (Int32) -> Void +private typealias TestExceptionHandler = @convention(c) (NSException) -> Void +private let exceptionHandlerCallLock = NSLock() +private var exceptionHandlerCalls = 0 + +private func osLoggerAdaptersTestSignalHandler(_: Int32) {} +private func osLoggerAdaptersTestExceptionHandler(_: NSException) {} +private func osLoggerAdaptersReplacementExceptionHandler(_: NSException) {} +private func osLoggerAdaptersCountingExceptionHandler(_: NSException) { + exceptionHandlerCallLock.lock() + exceptionHandlerCalls += 1 + exceptionHandlerCallLock.unlock() +} + +private func osLoggerAdaptersReentrantExceptionHandler(_ exception: NSException) { + osLoggerAdaptersCountingExceptionHandler(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 +} + private final class LoggerAdapterListener: NSObject, OSLogListener { var levels: [ONE_S_LOG_LEVEL] = [] 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, From 3f294a8899f8cdf708fe2e905bbd33862ed711e4 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 12 Aug 2026 14:01:39 -0700 Subject: [PATCH 3/7] fix(logging): address crash pipeline review findings Harden shutdown and uploader cancellation races, constrain crash attribution, and keep fatal-path logging isolated from application listeners. Co-authored-by: Cursor --- .../OneSignal.xcodeproj/project.pbxproj | 4 + .../Source/Logging/OSLogCrashHandler.swift | 47 ++- .../Source/Logging/OSRemoteLogger.swift | 58 +++- .../Logging/OneSignalLogHttpSender.swift | 153 +++++---- .../OSLogCrashHandlerTests.swift | 290 ++++++++++++++++++ .../OSLoggerAdaptersTests.swift | 230 +------------- 6 files changed, 479 insertions(+), 303 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLogCrashHandlerTests.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 391b0f413..2ad50490c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -75,6 +75,7 @@ 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 */; }; + 497800000000000000000004 /* OSLogCrashHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 497800000000000000000003 /* 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 */; }; @@ -1797,6 +1798,7 @@ 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 = ""; }; + 497800000000000000000003 /* 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 = ""; }; @@ -2572,6 +2574,7 @@ 5BC1DE672C90C23E00CA8807 /* OSConsistencyManagerTests.swift */, 3C427AC8301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift */, 3C14E3AE2FAE54C006ED053 /* OSLoggerAdaptersTests.swift */, + 497800000000000000000003 /* OSLogCrashHandlerTests.swift */, 3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */, 3C23A21E2FCE0AA1001D32E3 /* OSResilientStorageTests.swift */, 3C23A21C2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift */, @@ -4608,6 +4611,7 @@ 3C23A21D2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift in Sources */, 3C427AC9301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift in Sources */, 3C14E3B52FAE54C006ED053 /* OSLoggerAdaptersTests.swift in Sources */, + 497800000000000000000004 /* 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 index e042f4765..fe385f4b4 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSLogCrashHandler.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSLogCrashHandler.swift @@ -30,7 +30,6 @@ import Darwin import Foundation -import OneSignalCore @_implementationOnly import OneSignalKMP private typealias OSExceptionHandler = @convention(c) (NSException) -> Void @@ -39,12 +38,43 @@ private func osLogUncaughtExceptionHandler(_ exception: NSException) { OSLogCrashHandler.handleActive(exception) } +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", + "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? @@ -122,12 +152,9 @@ final class OSLogCrashHandler: ILogCrashHandler { stacktrace: stacktrace ) do { - _ = try reporter.saveCrash(crash: crash) + try reporter.saveCrash(crash: crash) } catch { - OneSignalLog.onesignalLog( - .LL_ERROR, - message: "Unable to persist fatal crash: \(error.localizedDescription)" - ) + NSLog("[OneSignal crash] Unable to persist fatal crash: %@", error.localizedDescription) } } @@ -170,7 +197,13 @@ final class OSLogCrashHandler: ILogCrashHandler { } static func isOneSignalAtFault(_ stackSymbols: [String]) -> Bool { - stackSymbols.contains { $0.localizedCaseInsensitiveContains("OneSignal") } + stackSymbols.contains { frame in + let fields = frame.split(whereSeparator: { $0.isWhitespace }) + guard fields.count > 1 else { + return false + } + return oneSignalModules.contains(String(fields[1])) + } } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSRemoteLogger.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSRemoteLogger.swift index 0b2d642c7..3355b9461 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSRemoteLogger.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSRemoteLogger.swift @@ -48,6 +48,10 @@ public protocol OSStructuredRemoteLoggerProtocol: OSRemoteLoggerProtocol { ) } +public extension OSRemoteLoggerProtocol { + func start() {} +} + #if !targetEnvironment(macCatalyst) @_implementationOnly import OneSignalKMP @@ -55,12 +59,23 @@ public protocol OSStructuredRemoteLoggerProtocol: OSRemoteLoggerProtocol { private final class OSRemoteLoggerLifecycle { private let lock = NSLock() private var isStarted = false + private var isShuttingDown = false private var isShutdown = false - var isActive: Bool { + var canStartUploader: Bool { + lock.lock() + defer { lock.unlock() } + return isStarted && !isShuttingDown && !isShutdown + } + + func performIfTransportActive(_ work: () -> Void) -> Bool { lock.lock() defer { lock.unlock() } - return isStarted && !isShutdown + guard isStarted, !isShutdown else { + return false + } + work() + return true } func start() -> Bool { @@ -73,15 +88,21 @@ private final class OSRemoteLoggerLifecycle { return true } - func shutdown() -> Bool { + func beginShutdown() -> Bool { lock.lock() defer { lock.unlock() } - guard !isShutdown else { + guard !isShuttingDown, !isShutdown else { return false } - isShutdown = true + isShuttingDown = true return true } + + func finishShutdown() { + lock.lock() + isShutdown = true + lock.unlock() + } } final class OSCrashUploaderCoordinator { @@ -112,7 +133,19 @@ final class OSCrashUploaderCoordinator { func cancel(owner: UUID) { lock.lock() pendingUploads.removeAll { $0.owner == owner } + 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() } func finish(owner: UUID) { @@ -164,6 +197,7 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol { exporterLoggingEnabledProvider: exporterLoggingEnabledProvider ) let logger = IOSLogger() + let crashLogger = OSCrashLogger() let lifecycle = OSRemoteLoggerLifecycle() let fileStore = FileLogStore(rootPath: provider.crashStoragePath) let remoteTelemetry = LoggerFactory.shared.createRemoteTelemetry( @@ -171,7 +205,9 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol { httpSender: OneSignalLogHttpSender( logger: logger, isDiagnosticsEnabled: exporterLoggingEnabledProvider, - isEnabled: { lifecycle.isActive } + executeIfEnabled: { work in + lifecycle.performIfTransportActive(work) + } ) ) let crashTelemetry = LoggerFactory.shared.createCrashLocalTelemetry( @@ -180,7 +216,7 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol { ) let crashReporter = LoggerFactory.shared.createCrashReporter( crashTelemetry: crashTelemetry, - logger: logger + logger: crashLogger ) let crashHandler = OSLogCrashHandler(reporter: crashReporter) let crashUploader = LoggerFactory.shared.createCrashUploader( @@ -209,7 +245,12 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol { 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)") @@ -262,13 +303,14 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol { public func shutdown() { lifecycleOperationLock.lock() defer { lifecycleOperationLock.unlock() } - guard lifecycle.shutdown() else { + guard lifecycle.beginShutdown() else { return } OSCrashUploaderCoordinator.shared.cancel(owner: uploaderOwner) crashHandler.unregister() telemetry.shutdown() + lifecycle.finishShutdown() } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OneSignalLogHttpSender.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OneSignalLogHttpSender.swift index 0373b2caa..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 @@ -52,48 +53,43 @@ final class OneSignalLogHttpSender: ILogHttpSender { session: URLSession = OneSignalLogHttpSender.defaultSession, logger: ILogger = IOSLogger(), isDiagnosticsEnabled: @escaping () -> Bool = { false }, - isEnabled: @escaping () -> Bool = { true } + 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.isEnabled = isEnabled + self.executeIfEnabled = executeIfEnabled } init( requestSender: @escaping RequestSender, logger: ILogger = IOSLogger(), isDiagnosticsEnabled: @escaping () -> Bool = { false }, - isEnabled: @escaping () -> Bool = { true } + executeIfEnabled: @escaping (@escaping () -> Void) -> Bool = { work in + work() + return true + } ) { self.requestSender = requestSender self.logger = logger self.isDiagnosticsEnabled = isDiagnosticsEnabled - self.isEnabled = isEnabled + self.executeIfEnabled = executeIfEnabled } private let requestSender: RequestSender private let logger: ILogger private let isDiagnosticsEnabled: () -> Bool - private let isEnabled: () -> Bool + private let executeIfEnabled: (@escaping () -> Void) -> Bool func send( request: LogHttpRequest, completionHandler: @escaping (LogHttpResponse?, Error?) -> Void ) { - guard isEnabled() else { - completionHandler( - LogHttpResponse( - success: false, - statusCode: Self.transportFailureStatusCode, - message: "Remote logging is disabled" - ), - nil - ) - return - } guard let url = URL(string: request.url) else { completionHandler( LogHttpResponse( @@ -112,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..cef200dac --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLogCrashHandlerTests.swift @@ -0,0 +1,290 @@ +/* + 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"] + ) + + 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"] + ) + + XCTAssertTrue(try FileManager.default.contentsOfDirectory(atPath: temporaryDirectory.path).isEmpty) + } + + func testIgnoresOneSignalSubstringOutsideModuleField() { + XCTAssertFalse( + OSLogCrashHandler.isOneSignalAtFault( + ["0 ExampleApp 0x000000 OneSignalNotificationCallback + 1"] + ) + ) + } + + func testRecognizesKnownOneSignalModule() { + XCTAssertTrue( + OSLogCrashHandler.isOneSignalAtFault( + ["0 OneSignalNotifications 0x000000 NotificationHandler + 1"] + ) + ) + } + + 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 testUploaderCoordinatorCancelActiveStartsNext() { + 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) + 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 00260b59f..4d205efc5 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLoggerAdaptersTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLoggerAdaptersTests.swift @@ -25,7 +25,6 @@ THE SOFTWARE. */ -import Darwin import Foundation import OneSignalCore import OneSignalKMP @@ -171,7 +170,7 @@ final class OSLoggerAdaptersTests: XCTestCase { var requestStarted = false let sender = OneSignalLogHttpSender( requestSender: { _, _ in requestStarted = true }, - isEnabled: { false } + executeIfEnabled: { _ in false } ) let request = LogHttpRequest( url: "https://example.com/sdk/log", @@ -184,6 +183,7 @@ final class OSLoggerAdaptersTests: XCTestCase { 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() } @@ -238,180 +238,6 @@ final class OSLoggerAdaptersTests: XCTestCase { ) } - func testCrashHandlerSynchronouslyPersistsUncaughtException() throws { - let store = FileLogStore(rootPath: temporaryDirectory.path) - let logger = IOSLogger() - let telemetry = LoggerFactory.shared.createCrashLocalTelemetry( - platformProvider: makePlatformProvider(), - fileStore: store - ) - let reporter = LoggerFactory.shared.createCrashReporter( - crashTelemetry: telemetry, - logger: logger - ) - let handler = OSLogCrashHandler(reporter: reporter) - let exception = NSException( - name: NSExceptionName("TestException"), - reason: "test crash", - userInfo: nil - ) - - handler.handle( - exception: exception, - stackSymbols: ["0 OneSignalCore 0x000000 OneSignalExample + 1"] - ) - - XCTAssertEqual( - try FileManager.default.contentsOfDirectory(atPath: temporaryDirectory.path) - .filter { $0.hasSuffix(".otlp") } - .count, - 1 - ) - } - - func testCrashHandlerIgnoresCrashWithoutOneSignalFrames() throws { - let store = FileLogStore(rootPath: temporaryDirectory.path) - let telemetry = LoggerFactory.shared.createCrashLocalTelemetry( - platformProvider: makePlatformProvider(), - fileStore: store - ) - let reporter = LoggerFactory.shared.createCrashReporter( - crashTelemetry: telemetry, - logger: IOSLogger() - ) - let handler = OSLogCrashHandler(reporter: reporter) - - handler.handle( - exception: NSException(name: NSExceptionName("HostException"), reason: nil), - stackSymbols: ["0 ExampleApp 0x000000 AppDelegate + 1"] - ) - - XCTAssertTrue(try FileManager.default.contentsOfDirectory(atPath: temporaryDirectory.path).isEmpty) - } - - func testCrashHandlerDoesNotReplaceHostSignalHandler() { - let store = FileLogStore(rootPath: temporaryDirectory.path) - let telemetry = LoggerFactory.shared.createCrashLocalTelemetry( - platformProvider: makePlatformProvider(), - fileStore: store - ) - let reporter = LoggerFactory.shared.createCrashReporter( - crashTelemetry: telemetry, - logger: IOSLogger() - ) - let handler = OSLogCrashHandler(reporter: reporter) - let originalHandler = Darwin.signal(SIGABRT, osLoggerAdaptersTestSignalHandler) - defer { Darwin.signal(SIGABRT, originalHandler) } - - handler.initialize() - defer { handler.unregister() } - let installedHandler = Darwin.signal(SIGABRT, osLoggerAdaptersTestSignalHandler) - Darwin.signal(SIGABRT, installedHandler) - - XCTAssertEqual( - signalHandlerAddress(installedHandler), - signalHandlerAddress(osLoggerAdaptersTestSignalHandler) - ) - } - - func testCrashHandlerRestoresPreviousExceptionHandler() { - let handler = makeCrashHandler() - let originalHandler = NSGetUncaughtExceptionHandler() - NSSetUncaughtExceptionHandler(osLoggerAdaptersTestExceptionHandler) - defer { NSSetUncaughtExceptionHandler(originalHandler) } - - handler.initialize() - handler.unregister() - - XCTAssertEqual( - exceptionHandlerAddress(NSGetUncaughtExceptionHandler()), - exceptionHandlerAddress(osLoggerAdaptersTestExceptionHandler) - ) - } - - func testCrashHandlerPreservesHandlerInstalledAfterIt() { - let handler = makeCrashHandler() - let originalHandler = NSGetUncaughtExceptionHandler() - defer { NSSetUncaughtExceptionHandler(originalHandler) } - handler.initialize() - - NSSetUncaughtExceptionHandler(osLoggerAdaptersReplacementExceptionHandler) - handler.unregister() - - XCTAssertEqual( - exceptionHandlerAddress(NSGetUncaughtExceptionHandler()), - exceptionHandlerAddress(osLoggerAdaptersReplacementExceptionHandler) - ) - } - - func testCrashHandlerForwardsInFlightCallbackAfterUnregister() { - resetExceptionHandlerCallCount() - let handler = makeCrashHandler() - let originalHandler = NSGetUncaughtExceptionHandler() - NSSetUncaughtExceptionHandler(osLoggerAdaptersCountingExceptionHandler) - defer { NSSetUncaughtExceptionHandler(originalHandler) } - handler.initialize() - handler.unregister() - - OSLogCrashHandler.handleActive( - NSException(name: NSExceptionName("InFlightException"), reason: nil) - ) - - XCTAssertEqual(exceptionHandlerCallCount(), 1) - } - - func testCrashHandlerStopsReentrantPreviousHandler() { - resetExceptionHandlerCallCount() - let handler = makeCrashHandler() - let originalHandler = NSGetUncaughtExceptionHandler() - NSSetUncaughtExceptionHandler(osLoggerAdaptersReentrantExceptionHandler) - defer { NSSetUncaughtExceptionHandler(originalHandler) } - handler.initialize() - defer { handler.unregister() } - - OSLogCrashHandler.handleActive( - NSException(name: NSExceptionName("ReentrantException"), reason: nil) - ) - - XCTAssertEqual(exceptionHandlerCallCount(), 1) - } - - func testCrashUploaderCoordinatorSerializesUploaders() { - 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 testCrashUploaderCoordinatorCancelsPendingUploader() { - 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 testPlatformProviderReturnsInjectedIdentifiersAndPlatformMetadata() { let provider = makePlatformProvider() var firstInstallId: String? @@ -463,63 +289,11 @@ final class OSLoggerAdaptersTests: XCTestCase { ) } - 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: IOSLogger() - ) - return OSLogCrashHandler(reporter: reporter) - } - private func makeKotlinBytes(_ bytes: [UInt8]) -> KotlinByteArray { AppleByteArrayInterop.shared.toByteArray(data: Data(bytes)) } } -private typealias TestSignalHandler = @convention(c) (Int32) -> Void -private typealias TestExceptionHandler = @convention(c) (NSException) -> Void -private let exceptionHandlerCallLock = NSLock() -private var exceptionHandlerCalls = 0 - -private func osLoggerAdaptersTestSignalHandler(_: Int32) {} -private func osLoggerAdaptersTestExceptionHandler(_: NSException) {} -private func osLoggerAdaptersReplacementExceptionHandler(_: NSException) {} -private func osLoggerAdaptersCountingExceptionHandler(_: NSException) { - exceptionHandlerCallLock.lock() - exceptionHandlerCalls += 1 - exceptionHandlerCallLock.unlock() -} - -private func osLoggerAdaptersReentrantExceptionHandler(_ exception: NSException) { - osLoggerAdaptersCountingExceptionHandler(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 -} - private final class LoggerAdapterListener: NSObject, OSLogListener { var levels: [ONE_S_LOG_LEVEL] = [] From d5258934319221eedad7ea143ac78edd5d3d6eaa Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 12 Aug 2026 14:37:17 -0700 Subject: [PATCH 4/7] fix(logging): attribute crashes to the throwing module Avoid treating host exceptions as OneSignal faults merely because a deeper callback frame belongs to the SDK. Co-authored-by: Cursor --- .../Source/Logging/OSLogCrashHandler.swift | 26 +++++++++++++++---- .../OSLogCrashHandlerTests.swift | 13 ++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSLogCrashHandler.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSLogCrashHandler.swift index fe385f4b4..c3a341129 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSLogCrashHandler.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSLogCrashHandler.swift @@ -75,6 +75,11 @@ final class OSLogCrashHandler: ILogCrashHandler { "OneSignalOutcomes", "OneSignalUser" ] + private static let exceptionRuntimeModules: Set = [ + "CoreFoundation", + "libobjc", + "libobjc.A.dylib" + ] private static let registryLock = NSLock() private static let handlingThreadKey = "com.onesignal.logger.handling-exception" private static var active: OSLogCrashHandler? @@ -197,13 +202,24 @@ final class OSLogCrashHandler: ILogCrashHandler { } static func isOneSignalAtFault(_ stackSymbols: [String]) -> Bool { - stackSymbols.contains { frame in - let fields = frame.split(whereSeparator: { $0.isWhitespace }) - guard fields.count > 1 else { - return false + for frame in stackSymbols { + guard let module = moduleName(from: frame) else { + continue + } + if exceptionRuntimeModules.contains(module) { + continue } - return oneSignalModules.contains(String(fields[1])) + return oneSignalModules.contains(module) + } + return false + } + + private static func moduleName(from frame: String) -> String? { + let fields = frame.split(whereSeparator: { $0.isWhitespace }) + guard fields.count > 1 else { + return nil } + return String(fields[1]) } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLogCrashHandlerTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLogCrashHandlerTests.swift index cef200dac..c7beeb90f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLogCrashHandlerTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLogCrashHandlerTests.swift @@ -92,6 +92,19 @@ final class OSLogCrashHandlerTests: XCTestCase { ) } + func testIgnoresOneSignalCallbackBelowHostThrowingFrame() { + XCTAssertFalse( + OSLogCrashHandler.isOneSignalAtFault( + [ + "0 CoreFoundation 0x000000 __exceptionPreprocess + 1", + "1 libobjc.A.dylib 0x000000 objc_exception_throw + 1", + "2 ExampleApp 0x000000 HostCallback + 1", + "3 OneSignalCore 0x000000 OneSignalCallback + 1" + ] + ) + ) + } + func testDoesNotReplaceHostSignalHandler() { let handler = makeCrashHandler() let originalHandler = Darwin.signal(SIGABRT, osLogCrashTestSignalHandler) From b44a57546c031bb42a5a9fe9585fa3988ee5bc40 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 13 Aug 2026 18:22:10 -0700 Subject: [PATCH 5/7] fix(logging): address uploader lifecycle feedback Avoid startup reentrancy deadlocks, keep active uploads serialized through completion, and recognize crashes originating in the KMP image. Co-authored-by: Cursor --- .../Source/Logging/OSLogCrashHandler.swift | 1 + .../Source/Logging/OSRemoteLogger.swift | 15 ++------------- .../OSLogCrashHandlerTests.swift | 12 +++++++++++- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSLogCrashHandler.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSLogCrashHandler.swift index c3a341129..555abbcc6 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSLogCrashHandler.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSLogCrashHandler.swift @@ -68,6 +68,7 @@ final class OSLogCrashHandler: ILogCrashHandler { "OneSignalExtension", "OneSignalFramework", "OneSignalInAppMessages", + "OneSignalKMP", "OneSignalLiveActivities", "OneSignalLocation", "OneSignalNotifications", diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSRemoteLogger.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSRemoteLogger.swift index 3355b9461..38df28cc4 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSRemoteLogger.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSRemoteLogger.swift @@ -133,19 +133,7 @@ final class OSCrashUploaderCoordinator { func cancel(owner: UUID) { lock.lock() pendingUploads.removeAll { $0.owner == owner } - 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() } func finish(owner: UUID) { @@ -236,12 +224,13 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol { public func start() { lifecycleOperationLock.lock() - defer { lifecycleOperationLock.unlock() } guard lifecycle.start() else { + lifecycleOperationLock.unlock() return } crashHandler.initialize() + lifecycleOperationLock.unlock() let owner = uploaderOwner let crashUploader = self.crashUploader let logger = self.logger diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLogCrashHandlerTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLogCrashHandlerTests.swift index c7beeb90f..d68c704b5 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLogCrashHandlerTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLogCrashHandlerTests.swift @@ -92,6 +92,14 @@ final class OSLogCrashHandlerTests: XCTestCase { ) } + func testRecognizesOneSignalKMPModule() { + XCTAssertTrue( + OSLogCrashHandler.isOneSignalAtFault( + ["0 OneSignalKMP 0x000000 kfun:com.onesignal.logger.LogCrashReporter + 1"] + ) + ) + } + func testIgnoresOneSignalCallbackBelowHostThrowingFrame() { XCTAssertFalse( OSLogCrashHandler.isOneSignalAtFault( @@ -219,7 +227,7 @@ final class OSLogCrashHandlerTests: XCTestCase { XCTAssertEqual(started, ["first"]) } - func testUploaderCoordinatorCancelActiveStartsNext() { + func testUploaderCoordinatorCancelActiveWaitsForFinishBeforeStartingNext() { let coordinator = OSCrashUploaderCoordinator() let firstOwner = UUID() let secondOwner = UUID() @@ -232,6 +240,8 @@ final class OSLogCrashHandlerTests: XCTestCase { started.append("second") } coordinator.cancel(owner: firstOwner) + + XCTAssertEqual(started, ["first"]) coordinator.finish(owner: firstOwner) XCTAssertEqual(started, ["first", "second"]) From 9fb883c8c4537c1963f89b451ec7ee2b65f05d6d Mon Sep 17 00:00:00 2001 From: Fadi George Date: Fri, 14 Aug 2026 15:42:32 -0700 Subject: [PATCH 6/7] chore(project): regenerate Xcode file references --- .../OneSignal.xcodeproj/project.pbxproj | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 2ad50490c..388d13f78 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -75,13 +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 */; }; - 497800000000000000000004 /* OSLogCrashHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 497800000000000000000003 /* OSLogCrashHandlerTests.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 */; }; - 497800000000000000000002 /* OSLogCrashHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 497800000000000000000001 /* OSLogCrashHandler.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 */; }; @@ -1798,12 +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 = ""; }; - 497800000000000000000003 /* OSLogCrashHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLogCrashHandlerTests.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 = ""; }; - 497800000000000000000001 /* OSLogCrashHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLogCrashHandler.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 = ""; }; @@ -2299,7 +2299,7 @@ 3C14E3AB2FAE54C006ED053 /* FileLogStore.swift */, 3C14E3AA2FAE54C006ED053 /* OneSignalLogHttpSender.swift */, 3C14E3A92FAE54C006ED053 /* IOSLogger.swift */, - 497800000000000000000001 /* OSLogCrashHandler.swift */, + ACD3284210A7DF2597594778 /* OSLogCrashHandler.swift */, 3C14E3AC2FAE54C006ED053 /* OSLoggerPlatformProvider.swift */, 6972EE491A57C79EFE56D4C8 /* OSRemoteLogger.swift */, ); @@ -2574,7 +2574,7 @@ 5BC1DE672C90C23E00CA8807 /* OSConsistencyManagerTests.swift */, 3C427AC8301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift */, 3C14E3AE2FAE54C006ED053 /* OSLoggerAdaptersTests.swift */, - 497800000000000000000003 /* OSLogCrashHandlerTests.swift */, + 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */, 3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */, 3C23A21E2FCE0AA1001D32E3 /* OSResilientStorageTests.swift */, 3C23A21C2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift */, @@ -4448,7 +4448,7 @@ 3C14E3B12FAE54C006ED053 /* OneSignalLogHttpSender.swift in Sources */, 3C4F9E4428A4466C009F453A /* OSOperationRepo.swift in Sources */, 3C14E3B02FAE54C006ED053 /* IOSLogger.swift in Sources */, - 497800000000000000000002 /* OSLogCrashHandler.swift in Sources */, + 698F58A488FCE503DFD5247F /* OSLogCrashHandler.swift in Sources */, 7732574D325D34CC7C498199 /* OSRemoteLogger.swift in Sources */, 3C11518B289ADEEB00565C41 /* OSEventProducer.swift in Sources */, 3C115165289A259500565C41 /* OneSignalOSCore.docc in Sources */, @@ -4611,7 +4611,7 @@ 3C23A21D2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift in Sources */, 3C427AC9301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift in Sources */, 3C14E3B52FAE54C006ED053 /* OSLoggerAdaptersTests.swift in Sources */, - 497800000000000000000004 /* OSLogCrashHandlerTests.swift in Sources */, + C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */, 3C23A21B2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; From 0ff51211c5f8acf23f15eefb9d6c5722a3e64028 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Fri, 14 Aug 2026 16:20:31 -0700 Subject: [PATCH 7/7] fix(logging): use dladdr for crash attribution --- .../Source/Logging/OSLogCrashHandler.swift | 102 +++++++++++--- .../OSLogCrashHandlerTests.swift | 130 +++++++++++++++--- 2 files changed, 190 insertions(+), 42 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSLogCrashHandler.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSLogCrashHandler.swift index 555abbcc6..5c941f53f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSLogCrashHandler.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSLogCrashHandler.swift @@ -38,6 +38,11 @@ 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) @@ -76,11 +81,6 @@ final class OSLogCrashHandler: ILogCrashHandler { "OneSignalOutcomes", "OneSignalUser" ] - private static let exceptionRuntimeModules: Set = [ - "CoreFoundation", - "libobjc", - "libobjc.A.dylib" - ] private static let registryLock = NSLock() private static let handlingThreadKey = "com.onesignal.logger.handling-exception" private static var active: OSLogCrashHandler? @@ -130,11 +130,19 @@ final class OSLogCrashHandler: ILogCrashHandler { } func handle(exception: NSException) { - handle(exception: exception, stackSymbols: exception.callStackSymbols) + handle( + exception: exception, + stackSymbols: exception.callStackSymbols, + resolvedFrames: Self.resolveStackFrames(exception.callStackReturnAddresses) + ) } - func handle(exception: NSException, stackSymbols: [String]) { - guard Self.isOneSignalAtFault(stackSymbols) else { + func handle( + exception: NSException, + stackSymbols: [String], + resolvedFrames: [OSResolvedStackFrame] + ) { + guard Self.isOneSignalAtFault(resolvedFrames) else { previousExceptionHandler?(exception) return } @@ -202,25 +210,79 @@ final class OSLogCrashHandler: ILogCrashHandler { } } - static func isOneSignalAtFault(_ stackSymbols: [String]) -> Bool { - for frame in stackSymbols { - guard let module = moduleName(from: frame) else { - continue + 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) } - if exceptionRuntimeModules.contains(module) { - continue + var info = Dl_info() + guard dladdr(pointer, &info) != 0 else { + return OSResolvedStackFrame(imagePath: nil, symbolName: nil) } - return oneSignalModules.contains(module) + return OSResolvedStackFrame( + imagePath: info.dli_fname.map { String(cString: $0) }, + symbolName: info.dli_sname.map { String(cString: $0) } + ) } - return false } - private static func moduleName(from frame: String) -> String? { - let fields = frame.split(whereSeparator: { $0.isWhitespace }) - guard fields.count > 1 else { + 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.. OSResolvedStackFrame { + OSResolvedStackFrame(imagePath: imagePath, symbolName: symbol) + } +} + +final class OSLogCrashAttributionTests: XCTestCase { func testIgnoresOneSignalSubstringOutsideModuleField() { XCTAssertFalse( - OSLogCrashHandler.isOneSignalAtFault( - ["0 ExampleApp 0x000000 OneSignalNotificationCallback + 1"] - ) + 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( - OSLogCrashHandler.isOneSignalAtFault( - ["0 OneSignalNotifications 0x000000 NotificationHandler + 1"] - ) + 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 testRecognizesOneSignalKMPModule() { + func testIgnoresOneSignalSymbolInSystemImage() { + XCTAssertFalse( + isOneSignal([frame("/usr/lib/libExample.dylib", symbol: "onesignal_Log")]) + ) + } + + func testSkipsSimulatorSystemImage() { XCTAssertTrue( - OSLogCrashHandler.isOneSignalAtFault( - ["0 OneSignalKMP 0x000000 kfun:com.onesignal.logger.LogCrashReporter + 1"] - ) + isOneSignal([ + frame( + "/Library/Developer/CoreSimulator/Volumes/iOS/RuntimeRoot/" + + "System/Library/Frameworks/Foundation.framework/Foundation", + symbol: "onesignal_Log" + ), + dynamicFrame("OneSignalOSCore") + ]) ) } - func testIgnoresOneSignalCallbackBelowHostThrowingFrame() { + func testIgnoresUnresolvedAndEmptyStacks() { XCTAssertFalse( - OSLogCrashHandler.isOneSignalAtFault( - [ - "0 CoreFoundation 0x000000 __exceptionPreprocess + 1", - "1 libobjc.A.dylib 0x000000 objc_exception_throw + 1", - "2 ExampleApp 0x000000 HostCallback + 1", - "3 OneSignalCore 0x000000 OneSignalCallback + 1" - ] - ) + 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) @@ -271,6 +356,7 @@ final class OSLogCrashHandlerTests: XCTestCase { exporterLoggingEnabledProvider: { true } ) } + } private typealias TestSignalHandler = @convention(c) (Int32) -> Void