From 2b5250fa24a11ab41aab769cf7013c99ca9a3cd9 Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Thu, 16 Jul 2026 10:34:22 +0200 Subject: [PATCH 01/33] feat: install KSCrash with production-safe monitors in SentryKSCrashIntegration Adds KSCrash crash handler installation to SentryKSCrashIntegration. - Defines KSCrashInstalling/KSCrashInstallerProvider protocols using only primitive types (String, UInt, Bool), so SentryTestUtils can conform without importing KSCrash. - Adds production KSCrashInstaller wrapping KSCrash.shared.install(with:). - Implements init in SentryKSCrashIntegration: installs with MonitorType.productionSafeMinimal monitors, respects cacheDirectoryPath and experimental.enableUnhandledCPPExceptionsV2, sets crashReporterInstalled and fatalDetected flags. - Wires SentryDependencyContainer to KSCrashInstallerProvider. - Adds TestKSCrashInstaller in SentryTestUtils and four integration tests. --- .../Sources/TestKSCrashInstaller.swift | 16 +++++ .../KSCrash/KSCrashInstaller.swift | 16 +++++ .../KSCrash/KSCrashInstalling.swift | 22 ++++++ .../KSCrash/SentryKSCrashIntegration.swift | 18 ++++- Sources/Swift/SentryDependencyContainer.swift | 8 +++ .../SentryKSCrashIntegrationTests.swift | 71 +++++++++++++++++++ 6 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 SentryTestUtils/Sources/TestKSCrashInstaller.swift create mode 100644 Sources/Swift/Integrations/KSCrash/KSCrashInstaller.swift create mode 100644 Sources/Swift/Integrations/KSCrash/KSCrashInstalling.swift create mode 100644 Tests/SentryTests/Integrations/KSCrash/SentryKSCrashIntegrationTests.swift diff --git a/SentryTestUtils/Sources/TestKSCrashInstaller.swift b/SentryTestUtils/Sources/TestKSCrashInstaller.swift new file mode 100644 index 0000000000..114f8a51f6 --- /dev/null +++ b/SentryTestUtils/Sources/TestKSCrashInstaller.swift @@ -0,0 +1,16 @@ +#if ENABLE_KSCRASH +@_spi(Private) @testable import Sentry + +@_spi(Private) public final class TestKSCrashInstaller: KSCrashInstalling { + public var installCalls: [(installPath: String, monitors: UInt, enableSwapCxaThrow: Bool)] = [] + public var shouldThrow: Error? + public var crashedLastLaunch: Bool = false + + public init() {} + + public func install(installPath: String, monitors: UInt, enableSwapCxaThrow: Bool) throws { + installCalls.append((installPath: installPath, monitors: monitors, enableSwapCxaThrow: enableSwapCxaThrow)) + if let error = shouldThrow { throw error } + } +} +#endif diff --git a/Sources/Swift/Integrations/KSCrash/KSCrashInstaller.swift b/Sources/Swift/Integrations/KSCrash/KSCrashInstaller.swift new file mode 100644 index 0000000000..6bbe115ae3 --- /dev/null +++ b/Sources/Swift/Integrations/KSCrash/KSCrashInstaller.swift @@ -0,0 +1,16 @@ +#if ENABLE_KSCRASH +@_implementationOnly import KSCrashInstallations + +/// Wraps `KSCrash.shared` for production use. +final class KSCrashInstaller: KSCrashInstalling { + func install(installPath: String, monitors: UInt, enableSwapCxaThrow: Bool) throws { + let config = KSCrashConfiguration() + config.installPath = installPath + config.monitors = MonitorType(rawValue: monitors) + config.enableSwapCxaThrow = enableSwapCxaThrow + try KSCrash.shared.install(with: config) + } + + var crashedLastLaunch: Bool { KSCrash.shared.crashedLastLaunch } +} +#endif diff --git a/Sources/Swift/Integrations/KSCrash/KSCrashInstalling.swift b/Sources/Swift/Integrations/KSCrash/KSCrashInstalling.swift new file mode 100644 index 0000000000..17c4f0fa5a --- /dev/null +++ b/Sources/Swift/Integrations/KSCrash/KSCrashInstalling.swift @@ -0,0 +1,22 @@ +#if ENABLE_KSCRASH +/// Abstraction over `KSCrash.shared` to keep `SentryKSCrashIntegration` testable without +/// linking KSCrash into every target that tests it. +@_spi(Private) public protocol KSCrashInstalling { + /// Install the crash handler. + /// - Parameters: + /// - installPath: The base directory for crash report storage. + /// - monitors: Raw monitor type bitmask (maps to `KSCrashMonitorType`). + /// - enableSwapCxaThrow: Whether to swap `__cxa_throw` for better C++ stacks. + /// - Throws: Any error from `KSCrash.installWithConfiguration(_:error:)`. + func install(installPath: String, monitors: UInt, enableSwapCxaThrow: Bool) throws + + /// Whether the previous run crashed. + var crashedLastLaunch: Bool { get } +} + +/// Provides a `KSCrashInstalling` instance for dependency injection. +@_spi(Private) public protocol KSCrashInstallerProvider { + /// The installer used to set up KSCrash crash reporting. + var kscrashInstaller: any KSCrashInstalling { get } +} +#endif diff --git a/Sources/Swift/Integrations/KSCrash/SentryKSCrashIntegration.swift b/Sources/Swift/Integrations/KSCrash/SentryKSCrashIntegration.swift index 6d6874f00a..da7267271d 100644 --- a/Sources/Swift/Integrations/KSCrash/SentryKSCrashIntegration.swift +++ b/Sources/Swift/Integrations/KSCrash/SentryKSCrashIntegration.swift @@ -7,7 +7,7 @@ import Foundation // MARK: - Dependency Provider /// Provides dependencies for `SentryKSCrashIntegration`. -typealias KSCrashIntegrationProvider = DateProviderProvider +typealias KSCrashIntegrationProvider = KSCrashInstallerProvider // MARK: - SentryKSCrashIntegration @@ -24,8 +24,22 @@ final class SentryKSCrashIntegration: self.options = options super.init() - + + do { + try dependencies.kscrashInstaller.install( + installPath: options.cacheDirectoryPath, + monitors: MonitorType.productionSafeMinimal.rawValue, + enableSwapCxaThrow: options.experimental.enableUnhandledCPPExceptionsV2 + ) + } catch { + SentrySDKLog.error("KSCrash install failed: \(error)") + return nil + } + SentrySDKInternal.crashReporterInstalled = true + if dependencies.kscrashInstaller.crashedLastLaunch { + SentrySDKInternal.fatalDetected = true + } } // MARK: - SwiftIntegration diff --git a/Sources/Swift/SentryDependencyContainer.swift b/Sources/Swift/SentryDependencyContainer.swift index ac844a8e31..1b108db400 100644 --- a/Sources/Swift/SentryDependencyContainer.swift +++ b/Sources/Swift/SentryDependencyContainer.swift @@ -519,6 +519,14 @@ extension SentryDependencyContainer: DateProviderProvider {} extension SentryDependencyContainer: AutoSessionTrackingProvider { } +#if ENABLE_KSCRASH +extension SentryDependencyContainer: KSCrashInstallerProvider { + public var kscrashInstaller: any KSCrashInstalling { + KSCrashInstaller() + } +} +#endif + protocol FileIOTrackerProvider { var fileIOTracker: SentryFileIOTracker { get } } diff --git a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrashIntegrationTests.swift b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrashIntegrationTests.swift new file mode 100644 index 0000000000..4524e96bd5 --- /dev/null +++ b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrashIntegrationTests.swift @@ -0,0 +1,71 @@ +#if ENABLE_KSCRASH +@_spi(Private) import SentryTestUtils +@_spi(Private) @testable import Sentry +import KSCrashInstallations +import XCTest + +class MockKSCrashDependencies: KSCrashIntegrationProvider { + let kscrashInstaller: any KSCrashInstalling + var dateProvider: SentryCurrentDateProvider { + SentryDependencyContainer.sharedInstance().dateProvider + } + + init(installer: any KSCrashInstalling = TestKSCrashInstaller()) { + self.kscrashInstaller = installer + } +} + +class SentryKSCrashIntegrationTests: XCTestCase { + + private func makeOptions(enableCrashHandler: Bool = true) -> Options { + let options = Options() + options.enableCrashHandler = enableCrashHandler + return options + } + + func testInstallCalledOnInit() { + let installer = TestKSCrashInstaller() + let deps = MockKSCrashDependencies(installer: installer) + let options = makeOptions() + + let sut = SentryKSCrashIntegration(with: options, dependencies: deps) + + XCTAssertNotNil(sut) + XCTAssertEqual(installer.installCalls.count, 1) + XCTAssertEqual(installer.installCalls[0].installPath, options.cacheDirectoryPath) + XCTAssertEqual(installer.installCalls[0].monitors, MonitorType.productionSafeMinimal.rawValue) + } + + func testCrashedLastLaunchSetsFlag() { + let installer = TestKSCrashInstaller() + installer.crashedLastLaunch = true + let deps = MockKSCrashDependencies(installer: installer) + + SentrySDKInternal.fatalDetected = false + let sut = SentryKSCrashIntegration(with: makeOptions(), dependencies: deps) + + XCTAssertNotNil(sut) + XCTAssertTrue(SentrySDKInternal.fatalDetected) + } + + func testInstallFailureReturnsNil() { + let installer = TestKSCrashInstaller() + installer.shouldThrow = NSError(domain: "TestKSCrash", code: -99) + let deps = MockKSCrashDependencies(installer: installer) + + let sut = SentryKSCrashIntegration(with: makeOptions(), dependencies: deps) + + XCTAssertNil(sut) + } + + func testInitSkipsWhenCrashHandlerDisabled() { + let installer = TestKSCrashInstaller() + let deps = MockKSCrashDependencies(installer: installer) + + let sut = SentryKSCrashIntegration(with: makeOptions(enableCrashHandler: false), dependencies: deps) + + XCTAssertNil(sut) + XCTAssertEqual(installer.installCalls.count, 0) + } +} +#endif From 8e2c53e162aa6f0301dfc85e402bb2eba280fcd9 Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Thu, 16 Jul 2026 10:59:00 +0200 Subject: [PATCH 02/33] fix: remove KSCrashInstallations import from test target to break dep cycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test file imported KSCrashInstallations directly to reference MonitorType.productionSafeMinimal.rawValue in an assertion. This created a dependency cycle: Installations → SentryTests+KSCrash → Sentry+KSCrash → Installations Fix: expose kscrashProductionSafeMonitors (a plain UInt) from KSCrashInstaller.swift, which already @_implementationOnly imports KSCrashInstallations, so the constant is available in SentrySwift without leaking KSCrash types. Tests now import only Sentry and SentryTestUtils, breaking the cycle. --- Sources/Swift/Integrations/KSCrash/KSCrashInstaller.swift | 4 ++++ .../Integrations/KSCrash/SentryKSCrashIntegrationTests.swift | 3 +-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Sources/Swift/Integrations/KSCrash/KSCrashInstaller.swift b/Sources/Swift/Integrations/KSCrash/KSCrashInstaller.swift index 6bbe115ae3..e30b4f3605 100644 --- a/Sources/Swift/Integrations/KSCrash/KSCrashInstaller.swift +++ b/Sources/Swift/Integrations/KSCrash/KSCrashInstaller.swift @@ -1,6 +1,10 @@ #if ENABLE_KSCRASH @_implementationOnly import KSCrashInstallations +/// The production-safe monitor set passed to KSCrash on install. +/// Exposed as a primitive so test targets can assert against it without importing KSCrash. +@_spi(Private) public let kscrashProductionSafeMonitors: UInt = MonitorType.productionSafeMinimal.rawValue + /// Wraps `KSCrash.shared` for production use. final class KSCrashInstaller: KSCrashInstalling { func install(installPath: String, monitors: UInt, enableSwapCxaThrow: Bool) throws { diff --git a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrashIntegrationTests.swift b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrashIntegrationTests.swift index 4524e96bd5..1371534484 100644 --- a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrashIntegrationTests.swift +++ b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrashIntegrationTests.swift @@ -1,7 +1,6 @@ #if ENABLE_KSCRASH @_spi(Private) import SentryTestUtils @_spi(Private) @testable import Sentry -import KSCrashInstallations import XCTest class MockKSCrashDependencies: KSCrashIntegrationProvider { @@ -33,7 +32,7 @@ class SentryKSCrashIntegrationTests: XCTestCase { XCTAssertNotNil(sut) XCTAssertEqual(installer.installCalls.count, 1) XCTAssertEqual(installer.installCalls[0].installPath, options.cacheDirectoryPath) - XCTAssertEqual(installer.installCalls[0].monitors, MonitorType.productionSafeMinimal.rawValue) + XCTAssertEqual(installer.installCalls[0].monitors, kscrashProductionSafeMonitors) } func testCrashedLastLaunchSetsFlag() { From 1d2469086b17e9b76f3dcc288ef1af68ab607e20 Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Thu, 16 Jul 2026 14:12:37 +0200 Subject: [PATCH 03/33] fix: treat KSCrash alreadyInstalled error as success KSCrash maintains a process-lifetime atomic bool (g_installed) in C. Once kscrash_install() succeeds, every subsequent call returns KSCrashInstallErrorAlreadyInstalled (domain KSCrashErrorDomain, code 1). This caused SentryKSCrashIntegration.init to return nil on any SDK re-initialisation (e.g. between test cases sharing a process), leaving crashReporterInstalled = false and breaking onLastRunStatusDetermined. Fix: catch the alreadyInstalled error in KSCrashInstaller and treat it as a no-op. The crash handler is already active; we still set the SDK flags correctly. --- .../Swift/Integrations/KSCrash/KSCrashInstaller.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Sources/Swift/Integrations/KSCrash/KSCrashInstaller.swift b/Sources/Swift/Integrations/KSCrash/KSCrashInstaller.swift index e30b4f3605..1b59f94e84 100644 --- a/Sources/Swift/Integrations/KSCrash/KSCrashInstaller.swift +++ b/Sources/Swift/Integrations/KSCrash/KSCrashInstaller.swift @@ -12,7 +12,15 @@ final class KSCrashInstaller: KSCrashInstalling { config.installPath = installPath config.monitors = MonitorType(rawValue: monitors) config.enableSwapCxaThrow = enableSwapCxaThrow - try KSCrash.shared.install(with: config) + do { + try KSCrash.shared.install(with: config) + } catch let error as NSError + where error.domain == "KSCrashErrorDomain" && error.code == 1 /* KSCrashInstallErrorAlreadyInstalled */ { + // KSCrash holds a process-lifetime C flag, so install() fails on every + // subsequent call within the same process (common during tests and SDK re-init). + // The crash handler is already running — treat this as success. + SentrySDKLog.debug("KSCrash already installed; continuing.") + } } var crashedLastLaunch: Bool { KSCrash.shared.crashedLastLaunch } From 84b43538d7353ad69c881b4f1df1948724af2b9b Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Thu, 16 Jul 2026 16:30:50 +0200 Subject: [PATCH 04/33] chore: abstract KSCrash to protocols so testutils doesn't need to import it WIP --- Sentry.xcodeproj/project.pbxproj | 1 + .../KSCrash/KSCrashInstaller.swift | 25 +++++++++++++++++-- .../KSCrash/KSCrashInstalling.swift | 22 ---------------- .../KSCrash/SentryKSCrashIntegration.swift | 2 +- Sources/Swift/SentryDependencyContainer.swift | 2 +- .../KSCrash/KSCrashInstallerTests.swift | 0 ...ift => TestSentryKSCrashIntegration.swift} | 0 7 files changed, 26 insertions(+), 26 deletions(-) delete mode 100644 Sources/Swift/Integrations/KSCrash/KSCrashInstalling.swift rename SentryTestUtils/Sources/TestKSCrashInstaller.swift => Tests/SentryTests/Integrations/KSCrash/KSCrashInstallerTests.swift (100%) rename Tests/SentryTests/Integrations/KSCrash/{SentryKSCrashIntegrationTests.swift => TestSentryKSCrashIntegration.swift} (100%) diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index 99462ca82f..245a234371 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -1827,6 +1827,7 @@ }; membershipExceptions = ( Info.plist, + Integrations/KSCrash/KSCrashInstallerTests.swift, "ViewCapture/SentryUIRedactBuilderTests+UIKit.swift", ); target = 63AA76641EB8CB2F00D153DE /* SentryTests */; diff --git a/Sources/Swift/Integrations/KSCrash/KSCrashInstaller.swift b/Sources/Swift/Integrations/KSCrash/KSCrashInstaller.swift index 1b59f94e84..6dba642fe8 100644 --- a/Sources/Swift/Integrations/KSCrash/KSCrashInstaller.swift +++ b/Sources/Swift/Integrations/KSCrash/KSCrashInstaller.swift @@ -1,12 +1,33 @@ #if ENABLE_KSCRASH @_implementationOnly import KSCrashInstallations +/// Abstraction over `KSCrash.shared` to keep `SentryKSCrashIntegration` testable without +/// linking KSCrash into every target that tests it. + protocol KSCrashInstalling { + /// Install the crash handler. + /// - Parameters: + /// - installPath: The base directory for crash report storage. + /// - monitors: Monitor types to enable. + /// - enableSwapCxaThrow: Whether to swap `__cxa_throw` for better C++ stacks. + /// - Throws: Any error from `KSCrash.installWithConfiguration(_:error:)`. + func install(installPath: String, monitors: UInt, enableSwapCxaThrow: Bool) throws + + /// Whether the previous run crashed. + var crashedLastLaunch: Bool { get } +} + +/// Provides a `KSCrashInstalling` instance for dependency injection. +protocol KSCrashInstallerProvider { + /// The installer used to set up KSCrash crash reporting. + var kscrashInstaller: any KSCrashInstalling { get } +} + /// The production-safe monitor set passed to KSCrash on install. /// Exposed as a primitive so test targets can assert against it without importing KSCrash. -@_spi(Private) public let kscrashProductionSafeMonitors: UInt = MonitorType.productionSafeMinimal.rawValue +let kscrashProductionSafeMonitors: UInt = MonitorType.productionSafeMinimal.rawValue /// Wraps `KSCrash.shared` for production use. -final class KSCrashInstaller: KSCrashInstalling { +struct KSCrashInstaller: KSCrashInstalling { func install(installPath: String, monitors: UInt, enableSwapCxaThrow: Bool) throws { let config = KSCrashConfiguration() config.installPath = installPath diff --git a/Sources/Swift/Integrations/KSCrash/KSCrashInstalling.swift b/Sources/Swift/Integrations/KSCrash/KSCrashInstalling.swift deleted file mode 100644 index 17c4f0fa5a..0000000000 --- a/Sources/Swift/Integrations/KSCrash/KSCrashInstalling.swift +++ /dev/null @@ -1,22 +0,0 @@ -#if ENABLE_KSCRASH -/// Abstraction over `KSCrash.shared` to keep `SentryKSCrashIntegration` testable without -/// linking KSCrash into every target that tests it. -@_spi(Private) public protocol KSCrashInstalling { - /// Install the crash handler. - /// - Parameters: - /// - installPath: The base directory for crash report storage. - /// - monitors: Raw monitor type bitmask (maps to `KSCrashMonitorType`). - /// - enableSwapCxaThrow: Whether to swap `__cxa_throw` for better C++ stacks. - /// - Throws: Any error from `KSCrash.installWithConfiguration(_:error:)`. - func install(installPath: String, monitors: UInt, enableSwapCxaThrow: Bool) throws - - /// Whether the previous run crashed. - var crashedLastLaunch: Bool { get } -} - -/// Provides a `KSCrashInstalling` instance for dependency injection. -@_spi(Private) public protocol KSCrashInstallerProvider { - /// The installer used to set up KSCrash crash reporting. - var kscrashInstaller: any KSCrashInstalling { get } -} -#endif diff --git a/Sources/Swift/Integrations/KSCrash/SentryKSCrashIntegration.swift b/Sources/Swift/Integrations/KSCrash/SentryKSCrashIntegration.swift index da7267271d..30a9e7aa5c 100644 --- a/Sources/Swift/Integrations/KSCrash/SentryKSCrashIntegration.swift +++ b/Sources/Swift/Integrations/KSCrash/SentryKSCrashIntegration.swift @@ -28,7 +28,7 @@ final class SentryKSCrashIntegration: do { try dependencies.kscrashInstaller.install( installPath: options.cacheDirectoryPath, - monitors: MonitorType.productionSafeMinimal.rawValue, + monitors: kscrashProductionSafeMonitors, // TODO: match this with SentryCrash's existing monitors enableSwapCxaThrow: options.experimental.enableUnhandledCPPExceptionsV2 ) } catch { diff --git a/Sources/Swift/SentryDependencyContainer.swift b/Sources/Swift/SentryDependencyContainer.swift index 1b108db400..ac76ca9195 100644 --- a/Sources/Swift/SentryDependencyContainer.swift +++ b/Sources/Swift/SentryDependencyContainer.swift @@ -521,7 +521,7 @@ extension SentryDependencyContainer: AutoSessionTrackingProvider { } #if ENABLE_KSCRASH extension SentryDependencyContainer: KSCrashInstallerProvider { - public var kscrashInstaller: any KSCrashInstalling { + var kscrashInstaller: any KSCrashInstalling { KSCrashInstaller() } } diff --git a/SentryTestUtils/Sources/TestKSCrashInstaller.swift b/Tests/SentryTests/Integrations/KSCrash/KSCrashInstallerTests.swift similarity index 100% rename from SentryTestUtils/Sources/TestKSCrashInstaller.swift rename to Tests/SentryTests/Integrations/KSCrash/KSCrashInstallerTests.swift diff --git a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrashIntegrationTests.swift b/Tests/SentryTests/Integrations/KSCrash/TestSentryKSCrashIntegration.swift similarity index 100% rename from Tests/SentryTests/Integrations/KSCrash/SentryKSCrashIntegrationTests.swift rename to Tests/SentryTests/Integrations/KSCrash/TestSentryKSCrashIntegration.swift From 9b22e6daf283710b568397f7155dc98aebd60e29 Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Mon, 20 Jul 2026 09:41:34 +0200 Subject: [PATCH 05/33] ref: rework the API to nest related types --- Sentry.xcodeproj/project.pbxproj | 2 +- .../Core/Integrations/Integrations.swift | 2 +- .../KSCrash/KSCrashInstaller.swift | 49 --------------- .../KSCrash/SentryKSCrash+Installer.swift | 39 ++++++++++++ .../KSCrash/SentryKSCrash+Integration.swift | 60 +++++++++++++++++++ .../Integrations/KSCrash/SentryKSCrash.swift | 12 ++++ .../KSCrash/SentryKSCrashIntegration.swift | 52 ---------------- Sources/Swift/SentryDependencyContainer.swift | 6 +- ...Tests.swift => MockKSCrashInstaller.swift} | 2 +- ...t => SentryKSCrash+IntegrationTests.swift} | 28 ++++----- 10 files changed, 129 insertions(+), 123 deletions(-) delete mode 100644 Sources/Swift/Integrations/KSCrash/KSCrashInstaller.swift create mode 100644 Sources/Swift/Integrations/KSCrash/SentryKSCrash+Installer.swift create mode 100644 Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift create mode 100644 Sources/Swift/Integrations/KSCrash/SentryKSCrash.swift delete mode 100644 Sources/Swift/Integrations/KSCrash/SentryKSCrashIntegration.swift rename Tests/SentryTests/Integrations/KSCrash/{KSCrashInstallerTests.swift => MockKSCrashInstaller.swift} (86%) rename Tests/SentryTests/Integrations/KSCrash/{TestSentryKSCrashIntegration.swift => SentryKSCrash+IntegrationTests.swift} (60%) diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index 245a234371..7414fde9f4 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -1827,7 +1827,7 @@ }; membershipExceptions = ( Info.plist, - Integrations/KSCrash/KSCrashInstallerTests.swift, + Integrations/KSCrash/MockKSCrashInstaller.swift, "ViewCapture/SentryUIRedactBuilderTests+UIKit.swift", ); target = 63AA76641EB8CB2F00D153DE /* SentryTests */; diff --git a/Sources/Swift/Core/Integrations/Integrations.swift b/Sources/Swift/Core/Integrations/Integrations.swift index 5310b474d5..40ebe4869b 100644 --- a/Sources/Swift/Core/Integrations/Integrations.swift +++ b/Sources/Swift/Core/Integrations/Integrations.swift @@ -54,7 +54,7 @@ private struct AnyIntegration { #endif #if ENABLE_KSCRASH - integrations.append(.init(SentryKSCrashIntegration.self)) + integrations.append(.init(SentryKSCrash.Integration.self)) #else integrations.append(.init(SentryCrashIntegration.self)) #endif diff --git a/Sources/Swift/Integrations/KSCrash/KSCrashInstaller.swift b/Sources/Swift/Integrations/KSCrash/KSCrashInstaller.swift deleted file mode 100644 index 6dba642fe8..0000000000 --- a/Sources/Swift/Integrations/KSCrash/KSCrashInstaller.swift +++ /dev/null @@ -1,49 +0,0 @@ -#if ENABLE_KSCRASH -@_implementationOnly import KSCrashInstallations - -/// Abstraction over `KSCrash.shared` to keep `SentryKSCrashIntegration` testable without -/// linking KSCrash into every target that tests it. - protocol KSCrashInstalling { - /// Install the crash handler. - /// - Parameters: - /// - installPath: The base directory for crash report storage. - /// - monitors: Monitor types to enable. - /// - enableSwapCxaThrow: Whether to swap `__cxa_throw` for better C++ stacks. - /// - Throws: Any error from `KSCrash.installWithConfiguration(_:error:)`. - func install(installPath: String, monitors: UInt, enableSwapCxaThrow: Bool) throws - - /// Whether the previous run crashed. - var crashedLastLaunch: Bool { get } -} - -/// Provides a `KSCrashInstalling` instance for dependency injection. -protocol KSCrashInstallerProvider { - /// The installer used to set up KSCrash crash reporting. - var kscrashInstaller: any KSCrashInstalling { get } -} - -/// The production-safe monitor set passed to KSCrash on install. -/// Exposed as a primitive so test targets can assert against it without importing KSCrash. -let kscrashProductionSafeMonitors: UInt = MonitorType.productionSafeMinimal.rawValue - -/// Wraps `KSCrash.shared` for production use. -struct KSCrashInstaller: KSCrashInstalling { - func install(installPath: String, monitors: UInt, enableSwapCxaThrow: Bool) throws { - let config = KSCrashConfiguration() - config.installPath = installPath - config.monitors = MonitorType(rawValue: monitors) - config.enableSwapCxaThrow = enableSwapCxaThrow - do { - try KSCrash.shared.install(with: config) - } catch let error as NSError - where error.domain == "KSCrashErrorDomain" && error.code == 1 /* KSCrashInstallErrorAlreadyInstalled */ { - // KSCrash holds a process-lifetime C flag, so install() fails on every - // subsequent call within the same process (common during tests and SDK re-init). - // The crash handler is already running — treat this as success. - SentrySDKLog.debug("KSCrash already installed; continuing.") - } - } - - var crashedLastLaunch: Bool { KSCrash.shared.crashedLastLaunch } -} -#endif diff --git a/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Installer.swift b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Installer.swift new file mode 100644 index 0000000000..a55e6fab4c --- /dev/null +++ b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Installer.swift @@ -0,0 +1,39 @@ +#if ENABLE_KSCRASH +@_implementationOnly import KSCrashInstallations + +extension SentryKSCrash { + protocol Installing { + /// Install the crash handler. + /// - Parameters: + /// - installPath: The base directory for crash report storage. + /// - monitors: Monitor types to enable. + /// - enableSwapCxaThrow: Whether to swap `__cxa_throw` for better C++ stacks. + /// - Throws: Any error from `KSCrash.installWithConfiguration(_:error:)`. + func install(installPath: String, monitors: UInt, enableSwapCxaThrow: Bool) throws + + /// Whether the previous run crashed. + var crashedLastLaunch: Bool { get } + } + + /// Configures and installs a crash handler + struct Installer: SentryKSCrash.Installing { + func install(installPath: String, monitors: UInt, enableSwapCxaThrow: Bool) throws { + let config = KSCrashConfiguration() + config.installPath = installPath + config.monitors = MonitorType(rawValue: monitors) + config.enableSwapCxaThrow = enableSwapCxaThrow + do { + try KSCrash.shared.install(with: config) + } catch let error as NSError + where error.domain == "KSCrashErrorDomain" && error.code == 1 /* KSCrashInstallErrorAlreadyInstalled */ { + // KSCrash holds a process-lifetime C flag, so install() fails on every + // subsequent call within the same process (common during tests and SDK re-init). + // The crash handler is already running — treat this as success. + SentrySDKLog.debug("KSCrash already installed; continuing.") + } + } + + var crashedLastLaunch: Bool { KSCrash.shared.crashedLastLaunch } + } +} +#endif diff --git a/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift new file mode 100644 index 0000000000..a6eea50290 --- /dev/null +++ b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift @@ -0,0 +1,60 @@ +#if ENABLE_KSCRASH +internal import _SentryPrivate +internal import KSCrashInstallations +import Foundation + +// MARK: - Integration +extension SentryKSCrash { + typealias DependencyProvider = SentryKSCrash.InstallerProvider + + /// Crash detectors matching SentryCrash's production monitor set: + /// Mach exceptions, signals, C++ exceptions, and NSExceptions. + /// KSCrash unconditionally adds its Required monitors (System, AppState, + /// UserInfo, Resource) on top of whatever is passed here. + static let productionSafeMonitors: MonitorType = [ + .machException, + .signal, + .cppException, + .nsException + ] + + final class Integration: NSObject, SwiftIntegration { + private weak var options: Options? + + // MARK: - Initialization + + init?(with options: Options, dependencies: Dependencies) { + guard options.enableCrashHandler else { + SentrySDKLog.debug("Not going to enable \(Self.name) because enableCrashHandler is disabled.") + return nil + } + + self.options = options + super.init() + + do { + try dependencies.kscrashInstaller.install( + installPath: options.cacheDirectoryPath, + monitors: productionSafeMonitors.rawValue, + enableSwapCxaThrow: options.experimental.enableUnhandledCPPExceptionsV2 + ) + } catch { + SentrySDKLog.error("KSCrash install failed: \(error)") + return nil + } + + SentrySDKInternal.crashReporterInstalled = true + if dependencies.kscrashInstaller.crashedLastLaunch { + SentrySDKInternal.fatalDetected = true + } + } + + // MARK: - SwiftIntegration + static var name: String { + "SentryKSCrashIntegration" + } + + func uninstall() {} + } +} +#endif diff --git a/Sources/Swift/Integrations/KSCrash/SentryKSCrash.swift b/Sources/Swift/Integrations/KSCrash/SentryKSCrash.swift new file mode 100644 index 0000000000..8c198cedc0 --- /dev/null +++ b/Sources/Swift/Integrations/KSCrash/SentryKSCrash.swift @@ -0,0 +1,12 @@ +#if ENABLE_KSCRASH +/// All types related to KSCrash should live under this type +enum SentryKSCrash { + /// Provides a `KSCrashInstalling` instance for dependency injection. + protocol InstallerProvider { + /// The installer used to set up KSCrash crash reporting. + associatedtype Installing: SentryKSCrash.Installing + + var kscrashInstaller: Installing { get } + } +} +#endif diff --git a/Sources/Swift/Integrations/KSCrash/SentryKSCrashIntegration.swift b/Sources/Swift/Integrations/KSCrash/SentryKSCrashIntegration.swift deleted file mode 100644 index 30a9e7aa5c..0000000000 --- a/Sources/Swift/Integrations/KSCrash/SentryKSCrashIntegration.swift +++ /dev/null @@ -1,52 +0,0 @@ -#if ENABLE_KSCRASH -// swiftlint:disable:next no_implementation_only_import -@_implementationOnly import KSCrashInstallations -internal import _SentryPrivate -import Foundation - -// MARK: - Dependency Provider - -/// Provides dependencies for `SentryKSCrashIntegration`. -typealias KSCrashIntegrationProvider = KSCrashInstallerProvider - -// MARK: - SentryKSCrashIntegration - -final class SentryKSCrashIntegration: NSObject, SwiftIntegration { - private weak var options: Options? - - // MARK: - Initialization - - init?(with options: Options, dependencies: Dependencies) { - guard options.enableCrashHandler else { - SentrySDKLog.debug("Not going to enable \(Self.name) because enableCrashHandler is disabled.") - return nil - } - - self.options = options - super.init() - - do { - try dependencies.kscrashInstaller.install( - installPath: options.cacheDirectoryPath, - monitors: kscrashProductionSafeMonitors, // TODO: match this with SentryCrash's existing monitors - enableSwapCxaThrow: options.experimental.enableUnhandledCPPExceptionsV2 - ) - } catch { - SentrySDKLog.error("KSCrash install failed: \(error)") - return nil - } - - SentrySDKInternal.crashReporterInstalled = true - if dependencies.kscrashInstaller.crashedLastLaunch { - SentrySDKInternal.fatalDetected = true - } - } - - // MARK: - SwiftIntegration - static var name: String { - "SentryKSCrashIntegration" - } - - func uninstall() {} -} -#endif diff --git a/Sources/Swift/SentryDependencyContainer.swift b/Sources/Swift/SentryDependencyContainer.swift index ac76ca9195..3e876670ac 100644 --- a/Sources/Swift/SentryDependencyContainer.swift +++ b/Sources/Swift/SentryDependencyContainer.swift @@ -520,9 +520,9 @@ extension SentryDependencyContainer: DateProviderProvider {} extension SentryDependencyContainer: AutoSessionTrackingProvider { } #if ENABLE_KSCRASH -extension SentryDependencyContainer: KSCrashInstallerProvider { - var kscrashInstaller: any KSCrashInstalling { - KSCrashInstaller() +extension SentryDependencyContainer: SentryKSCrash.InstallerProvider { + var kscrashInstaller: SentryKSCrash.Installer { + SentryKSCrash.Installer() } } #endif diff --git a/Tests/SentryTests/Integrations/KSCrash/KSCrashInstallerTests.swift b/Tests/SentryTests/Integrations/KSCrash/MockKSCrashInstaller.swift similarity index 86% rename from Tests/SentryTests/Integrations/KSCrash/KSCrashInstallerTests.swift rename to Tests/SentryTests/Integrations/KSCrash/MockKSCrashInstaller.swift index 114f8a51f6..4ae825f775 100644 --- a/Tests/SentryTests/Integrations/KSCrash/KSCrashInstallerTests.swift +++ b/Tests/SentryTests/Integrations/KSCrash/MockKSCrashInstaller.swift @@ -1,7 +1,7 @@ #if ENABLE_KSCRASH @_spi(Private) @testable import Sentry -@_spi(Private) public final class TestKSCrashInstaller: KSCrashInstalling { +@_spi(Private) public final class MockKSCrashInstaller: SentryKSCrash.Installing { public var installCalls: [(installPath: String, monitors: UInt, enableSwapCxaThrow: Bool)] = [] public var shouldThrow: Error? public var crashedLastLaunch: Bool = false diff --git a/Tests/SentryTests/Integrations/KSCrash/TestSentryKSCrashIntegration.swift b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift similarity index 60% rename from Tests/SentryTests/Integrations/KSCrash/TestSentryKSCrashIntegration.swift rename to Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift index 1371534484..8b1be56d35 100644 --- a/Tests/SentryTests/Integrations/KSCrash/TestSentryKSCrashIntegration.swift +++ b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift @@ -3,19 +3,15 @@ @_spi(Private) @testable import Sentry import XCTest -class MockKSCrashDependencies: KSCrashIntegrationProvider { - let kscrashInstaller: any KSCrashInstalling - var dateProvider: SentryCurrentDateProvider { - SentryDependencyContainer.sharedInstance().dateProvider - } +class MockKSCrashDependencies: SentryKSCrash.DependencyProvider { + let kscrashInstaller: MockKSCrashInstaller - init(installer: any KSCrashInstalling = TestKSCrashInstaller()) { + init(installer: MockKSCrashInstaller = .init()) { self.kscrashInstaller = installer } } class SentryKSCrashIntegrationTests: XCTestCase { - private func makeOptions(enableCrashHandler: Bool = true) -> Options { let options = Options() options.enableCrashHandler = enableCrashHandler @@ -23,45 +19,45 @@ class SentryKSCrashIntegrationTests: XCTestCase { } func testInstallCalledOnInit() { - let installer = TestKSCrashInstaller() + let installer = MockKSCrashInstaller() let deps = MockKSCrashDependencies(installer: installer) let options = makeOptions() - let sut = SentryKSCrashIntegration(with: options, dependencies: deps) + let sut = SentryKSCrash.Integration(with: options, dependencies: deps) XCTAssertNotNil(sut) XCTAssertEqual(installer.installCalls.count, 1) XCTAssertEqual(installer.installCalls[0].installPath, options.cacheDirectoryPath) - XCTAssertEqual(installer.installCalls[0].monitors, kscrashProductionSafeMonitors) + XCTAssertEqual(installer.installCalls[0].monitors, SentryKSCrash.productionSafeMonitors.rawValue) } func testCrashedLastLaunchSetsFlag() { - let installer = TestKSCrashInstaller() + let installer = MockKSCrashInstaller() installer.crashedLastLaunch = true let deps = MockKSCrashDependencies(installer: installer) SentrySDKInternal.fatalDetected = false - let sut = SentryKSCrashIntegration(with: makeOptions(), dependencies: deps) + let sut = SentryKSCrash.Integration(with: makeOptions(), dependencies: deps) XCTAssertNotNil(sut) XCTAssertTrue(SentrySDKInternal.fatalDetected) } func testInstallFailureReturnsNil() { - let installer = TestKSCrashInstaller() + let installer = MockKSCrashInstaller() installer.shouldThrow = NSError(domain: "TestKSCrash", code: -99) let deps = MockKSCrashDependencies(installer: installer) - let sut = SentryKSCrashIntegration(with: makeOptions(), dependencies: deps) + let sut = SentryKSCrash.Integration(with: makeOptions(), dependencies: deps) XCTAssertNil(sut) } func testInitSkipsWhenCrashHandlerDisabled() { - let installer = TestKSCrashInstaller() + let installer = MockKSCrashInstaller() let deps = MockKSCrashDependencies(installer: installer) - let sut = SentryKSCrashIntegration(with: makeOptions(enableCrashHandler: false), dependencies: deps) + let sut = SentryKSCrash.Integration(with: makeOptions(enableCrashHandler: false), dependencies: deps) XCTAssertNil(sut) XCTAssertEqual(installer.installCalls.count, 0) From 59a27624136f2a963d35f157128abf6047c76bc9 Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Mon, 20 Jul 2026 10:49:54 +0200 Subject: [PATCH 06/33] test: update to use swift testing because the future is now --- Tests/AGENTS.md | 7 +- .../KSCrash/MockKSCrashInstaller.swift | 2 +- .../SentryKSCrash+IntegrationTests.swift | 130 ++++++++++++------ 3 files changed, 96 insertions(+), 43 deletions(-) diff --git a/Tests/AGENTS.md b/Tests/AGENTS.md index c413583176..10a2e423d4 100644 --- a/Tests/AGENTS.md +++ b/Tests/AGENTS.md @@ -8,13 +8,16 @@ Test classes follow naming pattern `Tests`. Default to iOS (fastest) ```bash make test-ios FOR_AGENTS=true # all iOS tests -make test-ios FOR_AGENTS=true ONLY_TESTING=SentryTests/SentryHttpTransportTests # single class +make test-ios FOR_AGENTS=true ONLY_TESTING=SentryTests/SentryHttpTransportTests # single XCTest class make test-ios FOR_AGENTS=true ONLY_TESTING=SentryTests/SentryHttpTransportTests,SentryTests/SentryHubTests # multiple -make test-ios FOR_AGENTS=true ONLY_TESTING=SentryTests/SentryHttpTransportTests/testFlush_WhenNoInternet # single method +make test-ios FOR_AGENTS=true ONLY_TESTING=SentryTests/SentryHttpTransportTests/testFlush_WhenNoInternet # single XCTest method +make test-ios FOR_AGENTS=true ONLY_TESTING="SentryTests/MySuite/myTest_whenFoo_shouldBar()" # single Swift Testing function (note trailing `()`) make test FOR_AGENTS=true # all platforms make test-ui-critical # important UI tests ``` +**`ONLY_TESTING` and Swift Testing:** `-only-testing` works for both XCTest and Swift Testing. For Swift Testing, the identifier format is `Target/SuiteName/functionName()` — the function name must include the trailing `()`. Suite-level filtering (`Target/SuiteName`) also works. See [Apple docs](https://developer.apple.com/documentation/xcode/running-tests-and-interpreting-results) for details. + **Scope assessment:** - Specific feature → run related test classes with `FOR_AGENTS=true` diff --git a/Tests/SentryTests/Integrations/KSCrash/MockKSCrashInstaller.swift b/Tests/SentryTests/Integrations/KSCrash/MockKSCrashInstaller.swift index 4ae825f775..4375639cb2 100644 --- a/Tests/SentryTests/Integrations/KSCrash/MockKSCrashInstaller.swift +++ b/Tests/SentryTests/Integrations/KSCrash/MockKSCrashInstaller.swift @@ -1,7 +1,7 @@ #if ENABLE_KSCRASH @_spi(Private) @testable import Sentry -@_spi(Private) public final class MockKSCrashInstaller: SentryKSCrash.Installing { +final class MockKSCrashInstaller: SentryKSCrash.Installing { public var installCalls: [(installPath: String, monitors: UInt, enableSwapCxaThrow: Bool)] = [] public var shouldThrow: Error? public var crashedLastLaunch: Bool = false diff --git a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift index 8b1be56d35..1663253379 100644 --- a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift +++ b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift @@ -1,66 +1,116 @@ #if ENABLE_KSCRASH @_spi(Private) import SentryTestUtils @_spi(Private) @testable import Sentry -import XCTest +import Testing -class MockKSCrashDependencies: SentryKSCrash.DependencyProvider { - let kscrashInstaller: MockKSCrashInstaller +extension Tag { + @Tag static var ksCrashIntegration: Self +} - init(installer: MockKSCrashInstaller = .init()) { - self.kscrashInstaller = installer +struct SentryKSCrashIntegrationTests { + // MARK: - Helpers + private struct Fixture { + let installer = MockKSCrashInstaller() + lazy var deps = MockKSCrashDependencies(installer: installer) + + func makeOptions(enableCrashHandler: Bool = true) -> Options { + let options = Options() + options.enableCrashHandler = enableCrashHandler + return options + } } -} -class SentryKSCrashIntegrationTests: XCTestCase { - private func makeOptions(enableCrashHandler: Bool = true) -> Options { - let options = Options() - options.enableCrashHandler = enableCrashHandler - return options + // MARK: - Install + @Test(.tags(.ksCrashIntegration)) + func install_whenCrashHandlerEnabled_shouldCallInstallOnce() { + // -- Arrange -- + var fixture = Fixture() + let options = fixture.makeOptions() + + // -- Act -- + let sut = SentryKSCrash.Integration(with: options, dependencies: fixture.deps) + + // -- Assert -- + #expect(sut != nil) + #expect(fixture.installer.installCalls.count == 1) + #expect(fixture.installer.installCalls[0].installPath == options.cacheDirectoryPath) } - func testInstallCalledOnInit() { - let installer = MockKSCrashInstaller() - let deps = MockKSCrashDependencies(installer: installer) - let options = makeOptions() + @Test(.tags(.ksCrashIntegration)) + func install_whenCrashHandlerEnabled_shouldUseProductionSafeMonitors() { + // -- Arrange -- + var fixture = Fixture() - let sut = SentryKSCrash.Integration(with: options, dependencies: deps) + // -- Act -- + _ = SentryKSCrash.Integration(with: fixture.makeOptions(), dependencies: fixture.deps) - XCTAssertNotNil(sut) - XCTAssertEqual(installer.installCalls.count, 1) - XCTAssertEqual(installer.installCalls[0].installPath, options.cacheDirectoryPath) - XCTAssertEqual(installer.installCalls[0].monitors, SentryKSCrash.productionSafeMonitors.rawValue) + // -- Assert -- + #expect(fixture.installer.installCalls[0].monitors == SentryKSCrash.productionSafeMonitors.rawValue) } - func testCrashedLastLaunchSetsFlag() { - let installer = MockKSCrashInstaller() - installer.crashedLastLaunch = true - let deps = MockKSCrashDependencies(installer: installer) + @Test(.tags(.ksCrashIntegration)) + func install_whenCrashHandlerDisabled_shouldSkipInstall() { + // -- Arrange -- + var fixture = Fixture() - SentrySDKInternal.fatalDetected = false - let sut = SentryKSCrash.Integration(with: makeOptions(), dependencies: deps) + // -- Act -- + let sut = SentryKSCrash.Integration(with: fixture.makeOptions(enableCrashHandler: false), dependencies: fixture.deps) - XCTAssertNotNil(sut) - XCTAssertTrue(SentrySDKInternal.fatalDetected) + // -- Assert -- + #expect(sut == nil) + #expect(fixture.installer.installCalls.isEmpty) } - func testInstallFailureReturnsNil() { - let installer = MockKSCrashInstaller() - installer.shouldThrow = NSError(domain: "TestKSCrash", code: -99) - let deps = MockKSCrashDependencies(installer: installer) + @Test(.tags(.ksCrashIntegration)) + func install_whenInstallThrows_shouldReturnNil() { + // -- Arrange -- + var fixture = Fixture() + fixture.installer.shouldThrow = NSError(domain: "TestKSCrash", code: -99) - let sut = SentryKSCrash.Integration(with: makeOptions(), dependencies: deps) + // -- Act -- + let sut = SentryKSCrash.Integration(with: fixture.makeOptions(), dependencies: fixture.deps) - XCTAssertNil(sut) + // -- Assert -- + #expect(sut == nil) } - func testInitSkipsWhenCrashHandlerDisabled() { - let installer = MockKSCrashInstaller() - let deps = MockKSCrashDependencies(installer: installer) + // MARK: - Crash detection + @Test(.tags(.ksCrashIntegration)) + func install_whenCrashedLastLaunch_shouldSetFatalDetected() { + // -- Arrange -- + var fixture = Fixture() + fixture.installer.crashedLastLaunch = true + SentrySDKInternal.fatalDetected = false + + // -- Act -- + let sut = SentryKSCrash.Integration(with: fixture.makeOptions(), dependencies: fixture.deps) - let sut = SentryKSCrash.Integration(with: makeOptions(enableCrashHandler: false), dependencies: deps) + // -- Assert -- + #expect(sut != nil) + #expect(SentrySDKInternal.fatalDetected) + } - XCTAssertNil(sut) - XCTAssertEqual(installer.installCalls.count, 0) + @Test(.tags(.ksCrashIntegration)) + func install_whenDidNotCrashLastLaunch_shouldNotSetFatalDetected() { + // -- Arrange -- + var fixture = Fixture() + fixture.installer.crashedLastLaunch = false + SentrySDKInternal.fatalDetected = false + + // -- Act -- + _ = SentryKSCrash.Integration(with: fixture.makeOptions(), dependencies: fixture.deps) + + // -- Assert -- + #expect(!SentrySDKInternal.fatalDetected) + } +} + +// MARK: - Test doubles +final class MockKSCrashDependencies: SentryKSCrash.DependencyProvider { + let kscrashInstaller: MockKSCrashInstaller + + init(installer: MockKSCrashInstaller = .init()) { + self.kscrashInstaller = installer } } #endif From f41cd5b3e6b9ceffb04e96dc60533f02ad463b4a Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Mon, 20 Jul 2026 10:55:45 +0200 Subject: [PATCH 07/33] changelog: add changelog for feature --- CHANGELOG_V10.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG_V10.md b/CHANGELOG_V10.md index 2f512ec69b..7839c98b73 100644 --- a/CHANGELOG_V10.md +++ b/CHANGELOG_V10.md @@ -4,6 +4,7 @@ ### Features +- Install KSCrash crash handler in `Sentry+KSCrash` with production-safe monitors matching SentryCrash's existing monitor set (#8469) - Add `options.dataCollection` to configure data collection behaviour (#8448) - Allows dictionary initialization for `options.dataCollection` (#8371) - Renamed `options.dataCollection.queryParams` to `options.dataCollection.urlQueryParams` (#8414) From 24c72639c41850f810c9b99bcdcad99093f74b09 Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Mon, 20 Jul 2026 13:40:53 +0200 Subject: [PATCH 08/33] chore: correctly disable arm64e for mac catalyst builds --- Sources/Configuration/SentryKSCrash.xcconfig | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Sources/Configuration/SentryKSCrash.xcconfig b/Sources/Configuration/SentryKSCrash.xcconfig index 089e1dc4db..06d2da4871 100644 --- a/Sources/Configuration/SentryKSCrash.xcconfig +++ b/Sources/Configuration/SentryKSCrash.xcconfig @@ -28,7 +28,15 @@ MACH_O_TYPE = mh_dylib // see https://github.com/getsentry/sentry-cocoa/pull/8426 for more context ARCHS[sdk=appletvos*] = $(ARCHS_STANDARD) ARCHS[sdk=watchos*] = $(ARCHS_STANDARD) -ARCHS[sdk=maccatalyst*] = $(ARCHS_STANDARD) + +// Mac Catalyst is not affected by iOSPackagesShouldBuildARM64e, so KSCrash SPM packages +// never produce arm64e slices for maccatalyst. Mac Catalyst also doesn't have +// it's own SDK name, we so we need to do some working around to use the one +// build setting that shows Mac Catalyst is being build for: IS_MACCATALYST. +// Key off it via nested macro substitution instead. +SENTRY_KSCRASH_ARM64E_IF_NOT_CATALYST_YES = +SENTRY_KSCRASH_ARM64E_IF_NOT_CATALYST_NO = arm64e +ARCHS[sdk=macosx*] = arm64 x86_64 $(SENTRY_KSCRASH_ARM64E_IF_NOT_CATALYST_$(IS_MACCATALYST)) // Both this target and the base Sentry target share PRODUCT_MODULE_NAME=Sentry. Xcode's // explicit module scanner can't tell them apart and emits a spurious missing-dependency From 4ca43bd19a66c662d6eed19150fdc5464365d392 Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Tue, 21 Jul 2026 08:27:16 +0200 Subject: [PATCH 09/33] build: bump macOS deployment target to 10.15 for test targets only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swift Testing relies on Swift concurrency primitives (Actor, isolation()) which require macOS 10.15+. The KSCrash integration tests were rewritten to use Swift Testing, causing the TestV10KSCrash macOS build to fail with: 'Actor' is only available in macOS 10.15 or newer 'isolation()' is only available in macOS 10.15 or newer SentryTests.xcconfig includes DeploymentTargets.xcconfig (which sets 10.14) and then overrides MACOSX_DEPLOYMENT_TARGET to 10.15. This means all test targets (SentryTests and SentryTests+KSCrash, via SentryTestsKSCrash.xcconfig) pick up 10.15 while the SDK framework targets are unaffected — they include DeploymentTargets.xcconfig directly without going through SentryTests.xcconfig. --- Tests/Configuration/SentryTests.xcconfig | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Tests/Configuration/SentryTests.xcconfig b/Tests/Configuration/SentryTests.xcconfig index 4187913644..d0212742e7 100644 --- a/Tests/Configuration/SentryTests.xcconfig +++ b/Tests/Configuration/SentryTests.xcconfig @@ -1,5 +1,9 @@ #include "../../Sources/Configuration/DeploymentTargets.xcconfig" +// Bump macOS minimum to 10.15 for tests only — Swift Testing requires concurrency APIs +// (Actor, isolation()) that are not available below 10.15. The SDK itself stays at 10.14. +MACOSX_DEPLOYMENT_TARGET = 10.15 + // (originally added to Sentry.xcconfig in #3716, but it's only needed in the test targets, not the SDK target) // This config is required so the test code can access the SentryPrivate module. Removing this setting // leads to an error: no such module '_SentryPrivate' when including the XCFramework From f7391441a72d5cf6f21c4f10d0ff75ac83195b38 Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Tue, 21 Jul 2026 08:34:56 +0200 Subject: [PATCH 10/33] test(kscrash): revert Swift Testing conversion back to XCTest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts two commits: - 'test: update to use swift testing because the future is now' (d40cf78fc) — rewrote KSCrash integration tests with Swift Testing framework (@Test, #expect, struct suites, Tag extension) - 'build: bump macOS deployment target to 10.15 for test targets only' (46e7cef82) — was required because Swift Testing's concurrency APIs (Actor, isolation()) don't compile below macOS 10.15 Restores XCTestCase subclass with XCTAssert* assertions, removes the MACOSX_DEPLOYMENT_TARGET = 10.15 override from SentryTests.xcconfig, restores @_spi(Private) public on MockKSCrashInstaller, and removes the Swift Testing ONLY_TESTING note from Tests/AGENTS.md. --- Tests/AGENTS.md | 7 +- Tests/Configuration/SentryTests.xcconfig | 4 - .../SentryKSCrash+IntegrationTests.swift | 130 ++++++------------ 3 files changed, 42 insertions(+), 99 deletions(-) diff --git a/Tests/AGENTS.md b/Tests/AGENTS.md index 10a2e423d4..c413583176 100644 --- a/Tests/AGENTS.md +++ b/Tests/AGENTS.md @@ -8,16 +8,13 @@ Test classes follow naming pattern `Tests`. Default to iOS (fastest) ```bash make test-ios FOR_AGENTS=true # all iOS tests -make test-ios FOR_AGENTS=true ONLY_TESTING=SentryTests/SentryHttpTransportTests # single XCTest class +make test-ios FOR_AGENTS=true ONLY_TESTING=SentryTests/SentryHttpTransportTests # single class make test-ios FOR_AGENTS=true ONLY_TESTING=SentryTests/SentryHttpTransportTests,SentryTests/SentryHubTests # multiple -make test-ios FOR_AGENTS=true ONLY_TESTING=SentryTests/SentryHttpTransportTests/testFlush_WhenNoInternet # single XCTest method -make test-ios FOR_AGENTS=true ONLY_TESTING="SentryTests/MySuite/myTest_whenFoo_shouldBar()" # single Swift Testing function (note trailing `()`) +make test-ios FOR_AGENTS=true ONLY_TESTING=SentryTests/SentryHttpTransportTests/testFlush_WhenNoInternet # single method make test FOR_AGENTS=true # all platforms make test-ui-critical # important UI tests ``` -**`ONLY_TESTING` and Swift Testing:** `-only-testing` works for both XCTest and Swift Testing. For Swift Testing, the identifier format is `Target/SuiteName/functionName()` — the function name must include the trailing `()`. Suite-level filtering (`Target/SuiteName`) also works. See [Apple docs](https://developer.apple.com/documentation/xcode/running-tests-and-interpreting-results) for details. - **Scope assessment:** - Specific feature → run related test classes with `FOR_AGENTS=true` diff --git a/Tests/Configuration/SentryTests.xcconfig b/Tests/Configuration/SentryTests.xcconfig index d0212742e7..4187913644 100644 --- a/Tests/Configuration/SentryTests.xcconfig +++ b/Tests/Configuration/SentryTests.xcconfig @@ -1,9 +1,5 @@ #include "../../Sources/Configuration/DeploymentTargets.xcconfig" -// Bump macOS minimum to 10.15 for tests only — Swift Testing requires concurrency APIs -// (Actor, isolation()) that are not available below 10.15. The SDK itself stays at 10.14. -MACOSX_DEPLOYMENT_TARGET = 10.15 - // (originally added to Sentry.xcconfig in #3716, but it's only needed in the test targets, not the SDK target) // This config is required so the test code can access the SentryPrivate module. Removing this setting // leads to an error: no such module '_SentryPrivate' when including the XCFramework diff --git a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift index 1663253379..8b1be56d35 100644 --- a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift +++ b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift @@ -1,116 +1,66 @@ #if ENABLE_KSCRASH @_spi(Private) import SentryTestUtils @_spi(Private) @testable import Sentry -import Testing +import XCTest -extension Tag { - @Tag static var ksCrashIntegration: Self -} - -struct SentryKSCrashIntegrationTests { - // MARK: - Helpers - private struct Fixture { - let installer = MockKSCrashInstaller() - lazy var deps = MockKSCrashDependencies(installer: installer) - - func makeOptions(enableCrashHandler: Bool = true) -> Options { - let options = Options() - options.enableCrashHandler = enableCrashHandler - return options - } - } - - // MARK: - Install - @Test(.tags(.ksCrashIntegration)) - func install_whenCrashHandlerEnabled_shouldCallInstallOnce() { - // -- Arrange -- - var fixture = Fixture() - let options = fixture.makeOptions() - - // -- Act -- - let sut = SentryKSCrash.Integration(with: options, dependencies: fixture.deps) +class MockKSCrashDependencies: SentryKSCrash.DependencyProvider { + let kscrashInstaller: MockKSCrashInstaller - // -- Assert -- - #expect(sut != nil) - #expect(fixture.installer.installCalls.count == 1) - #expect(fixture.installer.installCalls[0].installPath == options.cacheDirectoryPath) + init(installer: MockKSCrashInstaller = .init()) { + self.kscrashInstaller = installer } +} - @Test(.tags(.ksCrashIntegration)) - func install_whenCrashHandlerEnabled_shouldUseProductionSafeMonitors() { - // -- Arrange -- - var fixture = Fixture() - - // -- Act -- - _ = SentryKSCrash.Integration(with: fixture.makeOptions(), dependencies: fixture.deps) - - // -- Assert -- - #expect(fixture.installer.installCalls[0].monitors == SentryKSCrash.productionSafeMonitors.rawValue) +class SentryKSCrashIntegrationTests: XCTestCase { + private func makeOptions(enableCrashHandler: Bool = true) -> Options { + let options = Options() + options.enableCrashHandler = enableCrashHandler + return options } - @Test(.tags(.ksCrashIntegration)) - func install_whenCrashHandlerDisabled_shouldSkipInstall() { - // -- Arrange -- - var fixture = Fixture() + func testInstallCalledOnInit() { + let installer = MockKSCrashInstaller() + let deps = MockKSCrashDependencies(installer: installer) + let options = makeOptions() - // -- Act -- - let sut = SentryKSCrash.Integration(with: fixture.makeOptions(enableCrashHandler: false), dependencies: fixture.deps) + let sut = SentryKSCrash.Integration(with: options, dependencies: deps) - // -- Assert -- - #expect(sut == nil) - #expect(fixture.installer.installCalls.isEmpty) + XCTAssertNotNil(sut) + XCTAssertEqual(installer.installCalls.count, 1) + XCTAssertEqual(installer.installCalls[0].installPath, options.cacheDirectoryPath) + XCTAssertEqual(installer.installCalls[0].monitors, SentryKSCrash.productionSafeMonitors.rawValue) } - @Test(.tags(.ksCrashIntegration)) - func install_whenInstallThrows_shouldReturnNil() { - // -- Arrange -- - var fixture = Fixture() - fixture.installer.shouldThrow = NSError(domain: "TestKSCrash", code: -99) - - // -- Act -- - let sut = SentryKSCrash.Integration(with: fixture.makeOptions(), dependencies: fixture.deps) - - // -- Assert -- - #expect(sut == nil) - } + func testCrashedLastLaunchSetsFlag() { + let installer = MockKSCrashInstaller() + installer.crashedLastLaunch = true + let deps = MockKSCrashDependencies(installer: installer) - // MARK: - Crash detection - @Test(.tags(.ksCrashIntegration)) - func install_whenCrashedLastLaunch_shouldSetFatalDetected() { - // -- Arrange -- - var fixture = Fixture() - fixture.installer.crashedLastLaunch = true SentrySDKInternal.fatalDetected = false + let sut = SentryKSCrash.Integration(with: makeOptions(), dependencies: deps) - // -- Act -- - let sut = SentryKSCrash.Integration(with: fixture.makeOptions(), dependencies: fixture.deps) - - // -- Assert -- - #expect(sut != nil) - #expect(SentrySDKInternal.fatalDetected) + XCTAssertNotNil(sut) + XCTAssertTrue(SentrySDKInternal.fatalDetected) } - @Test(.tags(.ksCrashIntegration)) - func install_whenDidNotCrashLastLaunch_shouldNotSetFatalDetected() { - // -- Arrange -- - var fixture = Fixture() - fixture.installer.crashedLastLaunch = false - SentrySDKInternal.fatalDetected = false + func testInstallFailureReturnsNil() { + let installer = MockKSCrashInstaller() + installer.shouldThrow = NSError(domain: "TestKSCrash", code: -99) + let deps = MockKSCrashDependencies(installer: installer) - // -- Act -- - _ = SentryKSCrash.Integration(with: fixture.makeOptions(), dependencies: fixture.deps) + let sut = SentryKSCrash.Integration(with: makeOptions(), dependencies: deps) - // -- Assert -- - #expect(!SentrySDKInternal.fatalDetected) + XCTAssertNil(sut) } -} -// MARK: - Test doubles -final class MockKSCrashDependencies: SentryKSCrash.DependencyProvider { - let kscrashInstaller: MockKSCrashInstaller + func testInitSkipsWhenCrashHandlerDisabled() { + let installer = MockKSCrashInstaller() + let deps = MockKSCrashDependencies(installer: installer) - init(installer: MockKSCrashInstaller = .init()) { - self.kscrashInstaller = installer + let sut = SentryKSCrash.Integration(with: makeOptions(enableCrashHandler: false), dependencies: deps) + + XCTAssertNil(sut) + XCTAssertEqual(installer.installCalls.count, 0) } } #endif From fec5a4dc7a13e6d98434b2cb666ba0ef565f8632 Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Tue, 21 Jul 2026 08:36:34 +0200 Subject: [PATCH 11/33] test(kscrash): rename tests to follow project naming convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the test_when_should() pattern from Tests/AGENTS.md to all four KSCrash integration tests: testInstallCalledOnInit → testInstall_whenCrashHandlerEnabled_shouldCallInstallOnce testCrashedLastLaunchSetsFlag → testInstall_whenCrashedLastLaunch_shouldSetFatalDetected testInstallFailureReturnsNil → testInstall_whenInstallThrows_shouldReturnNil testInitSkipsWhenCrashHandlerDisabled → testInstall_whenCrashHandlerDisabled_shouldSkipInstall --- .../KSCrash/SentryKSCrash+IntegrationTests.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift index 8b1be56d35..1b78977914 100644 --- a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift +++ b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift @@ -18,7 +18,7 @@ class SentryKSCrashIntegrationTests: XCTestCase { return options } - func testInstallCalledOnInit() { + func testInstall_whenCrashHandlerEnabled_shouldCallInstallOnce() { let installer = MockKSCrashInstaller() let deps = MockKSCrashDependencies(installer: installer) let options = makeOptions() @@ -31,7 +31,7 @@ class SentryKSCrashIntegrationTests: XCTestCase { XCTAssertEqual(installer.installCalls[0].monitors, SentryKSCrash.productionSafeMonitors.rawValue) } - func testCrashedLastLaunchSetsFlag() { + func testInstall_whenCrashedLastLaunch_shouldSetFatalDetected() { let installer = MockKSCrashInstaller() installer.crashedLastLaunch = true let deps = MockKSCrashDependencies(installer: installer) @@ -43,7 +43,7 @@ class SentryKSCrashIntegrationTests: XCTestCase { XCTAssertTrue(SentrySDKInternal.fatalDetected) } - func testInstallFailureReturnsNil() { + func testInstall_whenInstallThrows_shouldReturnNil() { let installer = MockKSCrashInstaller() installer.shouldThrow = NSError(domain: "TestKSCrash", code: -99) let deps = MockKSCrashDependencies(installer: installer) @@ -53,7 +53,7 @@ class SentryKSCrashIntegrationTests: XCTestCase { XCTAssertNil(sut) } - func testInitSkipsWhenCrashHandlerDisabled() { + func testInstall_whenCrashHandlerDisabled_shouldSkipInstall() { let installer = MockKSCrashInstaller() let deps = MockKSCrashDependencies(installer: installer) From 130cfe3a69db3d87ea817892826b1cabf2b7b3fe Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Tue, 21 Jul 2026 08:41:17 +0200 Subject: [PATCH 12/33] fix(kscrash): expose productionSafeMonitors as UInt to fix test build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MonitorType comes from KSCrashInstallations, which is imported @_implementationOnly in both SentryKSCrash+Integration.swift and SentryKSCrash+Installer.swift. @_implementationOnly makes the type completely invisible outside the Sentry module — even to @testable importers — so the test could not resolve MonitorType when it tried to call .rawValue on productionSafeMonitors. Fix: declare productionSafeMonitors as UInt (the raw type), computing it once with MonitorType([...]).rawValue inside the module where KSCrashInstallations is visible. The internal call site drops the now- redundant .rawValue suffix, and the test assertion compares UInt to UInt directly. --- .../Integrations/KSCrash/SentryKSCrash+Integration.swift | 6 +++--- .../KSCrash/SentryKSCrash+IntegrationTests.swift | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift index a6eea50290..eabc045de9 100644 --- a/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift +++ b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift @@ -11,12 +11,12 @@ extension SentryKSCrash { /// Mach exceptions, signals, C++ exceptions, and NSExceptions. /// KSCrash unconditionally adds its Required monitors (System, AppState, /// UserInfo, Resource) on top of whatever is passed here. - static let productionSafeMonitors: MonitorType = [ + static let productionSafeMonitors: UInt = MonitorType([ .machException, .signal, .cppException, .nsException - ] + ]).rawValue final class Integration: NSObject, SwiftIntegration { private weak var options: Options? @@ -35,7 +35,7 @@ extension SentryKSCrash { do { try dependencies.kscrashInstaller.install( installPath: options.cacheDirectoryPath, - monitors: productionSafeMonitors.rawValue, + monitors: productionSafeMonitors, enableSwapCxaThrow: options.experimental.enableUnhandledCPPExceptionsV2 ) } catch { diff --git a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift index 1b78977914..a1075fd476 100644 --- a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift +++ b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift @@ -28,7 +28,7 @@ class SentryKSCrashIntegrationTests: XCTestCase { XCTAssertNotNil(sut) XCTAssertEqual(installer.installCalls.count, 1) XCTAssertEqual(installer.installCalls[0].installPath, options.cacheDirectoryPath) - XCTAssertEqual(installer.installCalls[0].monitors, SentryKSCrash.productionSafeMonitors.rawValue) + XCTAssertEqual(installer.installCalls[0].monitors, SentryKSCrash.productionSafeMonitors) } func testInstall_whenCrashedLastLaunch_shouldSetFatalDetected() { From 220f37cbaf03d5e019da75f980b115539a270dd5 Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Tue, 21 Jul 2026 09:46:58 +0200 Subject: [PATCH 13/33] chore: remove unused membership exception --- Sentry.xcodeproj/project.pbxproj | 1 - 1 file changed, 1 deletion(-) diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index 7414fde9f4..99462ca82f 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -1827,7 +1827,6 @@ }; membershipExceptions = ( Info.plist, - Integrations/KSCrash/MockKSCrashInstaller.swift, "ViewCapture/SentryUIRedactBuilderTests+UIKit.swift", ); target = 63AA76641EB8CB2F00D153DE /* SentryTests */; From 731abd1754aab973b48cd79a0a30af2a9023697c Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Tue, 21 Jul 2026 12:40:29 +0200 Subject: [PATCH 14/33] chore: mirror SentryCrash install path configuration The integration appends 'KSCrash/' to the SDK's cacheDirectoryPath before passing it to the installer. The existing 'shouldCallInstallOnce' test was asserting installPath == cacheDirectoryPath directly, which became stale once the path was modified. Fix the stale assertion and add a dedicated test that mirrors the same URL construction, asserting the installer receives the expected subdirectory path. --- .../KSCrash/SentryKSCrash+Integration.swift | 8 ++++- .../SentryKSCrash+IntegrationTests.swift | 30 ++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift index eabc045de9..f460372552 100644 --- a/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift +++ b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift @@ -32,9 +32,15 @@ extension SentryKSCrash { self.options = options super.init() + // To match KSCrash & SentryCrash, we need to add 'KSCrash/' to the cacheDirectoryPath + let installPath = URL(fileURLWithPath: options.cacheDirectoryPath) + .appendingPathComponent("KSCrash") + .appendingPathComponent(Bundle.main.infoDictionary?["CFBundleIdentifier"] as? String ?? "Unknown") + .absoluteURL + do { try dependencies.kscrashInstaller.install( - installPath: options.cacheDirectoryPath, + installPath: installPath.path, monitors: productionSafeMonitors, enableSwapCxaThrow: options.experimental.enableUnhandledCPPExceptionsV2 ) diff --git a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift index a1075fd476..86f710f001 100644 --- a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift +++ b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift @@ -27,10 +27,24 @@ class SentryKSCrashIntegrationTests: XCTestCase { XCTAssertNotNil(sut) XCTAssertEqual(installer.installCalls.count, 1) - XCTAssertEqual(installer.installCalls[0].installPath, options.cacheDirectoryPath) XCTAssertEqual(installer.installCalls[0].monitors, SentryKSCrash.productionSafeMonitors) } + func testInstall_whenCrashHandlerEnabled_shouldAppendKSCrashBundleSubdirectory() { + let installer = MockKSCrashInstaller() + let deps = MockKSCrashDependencies(installer: installer) + let options = makeOptions() + let bundleID = Bundle.main.infoDictionary?["CFBundleIdentifier"] as? String ?? "Unknown" + let expectedPath = URL(fileURLWithPath: options.cacheDirectoryPath) + .appendingPathComponent("KSCrash") + .appendingPathComponent(bundleID) + .path + + _ = SentryKSCrash.Integration(with: options, dependencies: deps) + + XCTAssertEqual(installer.installCalls[0].installPath, expectedPath) + } + func testInstall_whenCrashedLastLaunch_shouldSetFatalDetected() { let installer = MockKSCrashInstaller() installer.crashedLastLaunch = true @@ -53,6 +67,20 @@ class SentryKSCrashIntegrationTests: XCTestCase { XCTAssertNil(sut) } + // MARK: - Last-run crash APIs + + func testLastRunStatus_whenCrashedLastLaunch_shouldReturnDidCrash() { + let installer = MockKSCrashInstaller() + installer.crashedLastLaunch = true + let deps = MockKSCrashDependencies(installer: installer) + + SentrySDKInternal.fatalDetected = false + SentrySDKInternal.crashReporterInstalled = false + _ = SentryKSCrash.Integration(with: makeOptions(), dependencies: deps) + + XCTAssertEqual(SentrySDK.lastRunStatus, .didCrash) + } + func testInstall_whenCrashHandlerDisabled_shouldSkipInstall() { let installer = MockKSCrashInstaller() let deps = MockKSCrashDependencies(installer: installer) From fbc56d54fc81e7970b012b4db0909b844bd9ab72 Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Tue, 21 Jul 2026 13:48:18 +0200 Subject: [PATCH 15/33] chore: wire into SentrySDK's crash status reporting --- Sources/Sentry/SentrySDKInternal.m | 8 ++++++++ .../Integrations/KSCrash/MockKSCrashInstaller.swift | 8 ++++++++ .../KSCrash/SentryKSCrash+IntegrationTests.swift | 8 -------- Tests/SentryTests/SentrySDKTests.swift | 7 +++++++ 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/Sources/Sentry/SentrySDKInternal.m b/Sources/Sentry/SentrySDKInternal.m index 2e95e35c73..12b3c4708a 100644 --- a/Sources/Sentry/SentrySDKInternal.m +++ b/Sources/Sentry/SentrySDKInternal.m @@ -497,7 +497,11 @@ + (void)setUser:(SentryUser *_Nullable)user + (BOOL)crashedLastRun { +#if ENABLE_KSCRASH + return fatalDetected; +#else return SentryDependencyContainer.sharedInstance.crashReporter.crashedLastLaunch; +#endif } + (NSInteger)lastRunStatus @@ -505,10 +509,14 @@ + (NSInteger)lastRunStatus if (!crashReporterInstalled) { return SentryLastRunStatusUnknown; } +#if ENABLE_KSCRASH + return fatalDetected ? SentryLastRunStatusDidCrash : SentryLastRunStatusDidNotCrash; +#else if (SentryDependencyContainer.sharedInstance.crashWrapper.crashedLastLaunch) { return SentryLastRunStatusDidCrash; } return SentryLastRunStatusDidNotCrash; +#endif } + (BOOL)detectedStartUpCrash diff --git a/Tests/SentryTests/Integrations/KSCrash/MockKSCrashInstaller.swift b/Tests/SentryTests/Integrations/KSCrash/MockKSCrashInstaller.swift index 4375639cb2..4e59fdd1a5 100644 --- a/Tests/SentryTests/Integrations/KSCrash/MockKSCrashInstaller.swift +++ b/Tests/SentryTests/Integrations/KSCrash/MockKSCrashInstaller.swift @@ -1,6 +1,14 @@ #if ENABLE_KSCRASH @_spi(Private) @testable import Sentry +final class MockKSCrashDependencies: SentryKSCrash.DependencyProvider { + let kscrashInstaller: MockKSCrashInstaller + + init(installer: MockKSCrashInstaller = .init()) { + self.kscrashInstaller = installer + } +} + final class MockKSCrashInstaller: SentryKSCrash.Installing { public var installCalls: [(installPath: String, monitors: UInt, enableSwapCxaThrow: Bool)] = [] public var shouldThrow: Error? diff --git a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift index 86f710f001..acaadfbc43 100644 --- a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift +++ b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift @@ -3,14 +3,6 @@ @_spi(Private) @testable import Sentry import XCTest -class MockKSCrashDependencies: SentryKSCrash.DependencyProvider { - let kscrashInstaller: MockKSCrashInstaller - - init(installer: MockKSCrashInstaller = .init()) { - self.kscrashInstaller = installer - } -} - class SentryKSCrashIntegrationTests: XCTestCase { private func makeOptions(enableCrashHandler: Bool = true) -> Options { let options = Options() diff --git a/Tests/SentryTests/SentrySDKTests.swift b/Tests/SentryTests/SentrySDKTests.swift index 275d4f8c65..b2fb210da5 100644 --- a/Tests/SentryTests/SentrySDKTests.swift +++ b/Tests/SentryTests/SentrySDKTests.swift @@ -264,10 +264,17 @@ class SentrySDKTests: XCTestCase { func testLastRunStatus_whenCrashStateLoadedAndCrashed_shouldReturnDidCrash() { // -- Arrange -- + #if ENABLE_KSCRASH + let installer = MockKSCrashInstaller() + installer.crashedLastLaunch = true + let dependencies = MockKSCrashDependencies(installer: installer) + _ = SentryKSCrash.Integration(with: .init(), dependencies: dependencies) + #else SentrySDKInternal.crashReporterInstalled = true let crashWrapper = TestSentryCrashWrapper(processInfoWrapper: ProcessInfo.processInfo) crashWrapper.internalCrashedLastLaunch = true SentryDependencyContainer.sharedInstance().crashWrapper = crashWrapper + #endif // -- Act -- let status = SentrySDK.lastRunStatus From 3640f20aa12f32cda4ec28da5958e04e87bea009 Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Thu, 23 Jul 2026 09:55:41 +0200 Subject: [PATCH 16/33] fix: use CFBundleName instead of CFBundleIdentifier --- .../Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift index f460372552..48396ac7d4 100644 --- a/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift +++ b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift @@ -35,7 +35,7 @@ extension SentryKSCrash { // To match KSCrash & SentryCrash, we need to add 'KSCrash/' to the cacheDirectoryPath let installPath = URL(fileURLWithPath: options.cacheDirectoryPath) .appendingPathComponent("KSCrash") - .appendingPathComponent(Bundle.main.infoDictionary?["CFBundleIdentifier"] as? String ?? "Unknown") + .appendingPathComponent(Bundle.main.infoDictionary?["CFBundleName"] as? String ?? "Unknown") .absoluteURL do { From 940d2e78625ecaafd1db54b09e06ab3ff2c43a5e Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Thu, 23 Jul 2026 10:34:19 +0200 Subject: [PATCH 17/33] feat: separate 'fatalDetected' into 'crashHandlerDetectedCrash' fatalDetected is also set by things like the watchdog integration --- Sources/Sentry/SentrySDKInternal.m | 27 ++++++++++--------- Sources/Sentry/include/SentrySDK+Private.h | 8 ++++++ .../KSCrash/SentryKSCrash+Integration.swift | 1 + .../SentryCrash/SentryCrashIntegration.swift | 1 + .../SentryKSCrash+IntegrationTests.swift | 2 ++ Tests/SentryTests/SentrySDKTests.swift | 13 ++------- 6 files changed, 28 insertions(+), 24 deletions(-) diff --git a/Sources/Sentry/SentrySDKInternal.m b/Sources/Sentry/SentrySDKInternal.m index 12b3c4708a..b39717e705 100644 --- a/Sources/Sentry/SentrySDKInternal.m +++ b/Sources/Sentry/SentrySDKInternal.m @@ -40,6 +40,7 @@ @implementation SentrySDKInternal static BOOL lastRunStatusCalled; static BOOL crashReporterInstalled; static BOOL fatalDetected; +static BOOL crashHandlerDetectedCrash; static SentryAppStartMeasurement *_Nullable sentrySDKappStartMeasurement; static NSObject *sentrySDKappStartMeasurementLock; static BOOL _detectedStartUpCrash; @@ -161,6 +162,16 @@ + (void)setFatalDetected:(BOOL)value fatalDetected = value; } ++ (BOOL)crashHandlerDetectedCrash +{ + return crashHandlerDetectedCrash; +} + ++ (void)setCrashHandlerDetectedCrash:(BOOL)value +{ + crashHandlerDetectedCrash = value; +} + /** * Not public, only for internal use. */ @@ -497,11 +508,7 @@ + (void)setUser:(SentryUser *_Nullable)user + (BOOL)crashedLastRun { -#if ENABLE_KSCRASH - return fatalDetected; -#else - return SentryDependencyContainer.sharedInstance.crashReporter.crashedLastLaunch; -#endif + return crashHandlerDetectedCrash; } + (NSInteger)lastRunStatus @@ -509,14 +516,7 @@ + (NSInteger)lastRunStatus if (!crashReporterInstalled) { return SentryLastRunStatusUnknown; } -#if ENABLE_KSCRASH - return fatalDetected ? SentryLastRunStatusDidCrash : SentryLastRunStatusDidNotCrash; -#else - if (SentryDependencyContainer.sharedInstance.crashWrapper.crashedLastLaunch) { - return SentryLastRunStatusDidCrash; - } - return SentryLastRunStatusDidNotCrash; -#endif + return crashHandlerDetectedCrash ? SentryLastRunStatusDidCrash : SentryLastRunStatusDidNotCrash; } + (BOOL)detectedStartUpCrash @@ -614,6 +614,7 @@ + (void)close crashReporterInstalled = NO; fatalDetected = NO; + crashHandlerDetectedCrash = NO; lastRunStatusCalled = NO; [SentryDependencyContainer.sharedInstance.crashWrapper stopBinaryImageCache]; diff --git a/Sources/Sentry/include/SentrySDK+Private.h b/Sources/Sentry/include/SentrySDK+Private.h index 0aabe0f679..b8b640026c 100644 --- a/Sources/Sentry/include/SentrySDK+Private.h +++ b/Sources/Sentry/include/SentrySDK+Private.h @@ -49,6 +49,14 @@ NS_ASSUME_NONNULL_BEGIN */ @property (nonatomic, class) BOOL fatalDetected; +/** + * Set to @c YES by the crash handler integration (SentryCrash or KSCrash) when the + * crash reporter reports a crash on the last launch. Unlike @c fatalDetected, this is + * never set by watchdog termination detection, so it accurately reflects whether a + * true crash (as opposed to an OS-level termination) occurred. + */ +@property (nonatomic, class) BOOL crashHandlerDetectedCrash; + + (void)setDetectedStartUpCrash:(BOOL)value; + (void)setAppStartMeasurement:(nullable SentryAppStartMeasurement *)appStartMeasurement; diff --git a/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift index 48396ac7d4..ae7eaa3264 100644 --- a/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift +++ b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift @@ -52,6 +52,7 @@ extension SentryKSCrash { SentrySDKInternal.crashReporterInstalled = true if dependencies.kscrashInstaller.crashedLastLaunch { SentrySDKInternal.fatalDetected = true + SentrySDKInternal.crashHandlerDetectedCrash = true } } diff --git a/Sources/Swift/Integrations/SentryCrash/SentryCrashIntegration.swift b/Sources/Swift/Integrations/SentryCrash/SentryCrashIntegration.swift index da129d0586..858ec7f3fe 100644 --- a/Sources/Swift/Integrations/SentryCrash/SentryCrashIntegration.swift +++ b/Sources/Swift/Integrations/SentryCrash/SentryCrashIntegration.swift @@ -159,6 +159,7 @@ final class SentryCrashIntegration: NSOb SentrySDKInternal.crashReporterInstalled = true if SentryDependencyContainer.sharedInstance().crashWrapper.crashedLastLaunch { SentrySDKInternal.fatalDetected = true + SentrySDKInternal.crashHandlerDetectedCrash = true } #if os(macOS) && !SENTRY_NO_UI_FRAMEWORK diff --git a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift index acaadfbc43..4717885ecd 100644 --- a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift +++ b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift @@ -43,10 +43,12 @@ class SentryKSCrashIntegrationTests: XCTestCase { let deps = MockKSCrashDependencies(installer: installer) SentrySDKInternal.fatalDetected = false + SentrySDKInternal.crashHandlerDetectedCrash = false let sut = SentryKSCrash.Integration(with: makeOptions(), dependencies: deps) XCTAssertNotNil(sut) XCTAssertTrue(SentrySDKInternal.fatalDetected) + XCTAssertTrue(SentrySDKInternal.crashHandlerDetectedCrash) } func testInstall_whenInstallThrows_shouldReturnNil() { diff --git a/Tests/SentryTests/SentrySDKTests.swift b/Tests/SentryTests/SentrySDKTests.swift index b2fb210da5..598f383d4b 100644 --- a/Tests/SentryTests/SentrySDKTests.swift +++ b/Tests/SentryTests/SentrySDKTests.swift @@ -233,7 +233,7 @@ class SentrySDKTests: XCTestCase { #if !SDK_V10 @available(*, deprecated, message: "Testing deprecated crashedLastRun API") func testCrashedLastRun() { - XCTAssertEqual(SentryDependencyContainer.sharedInstance().crashReporter.crashedLastLaunch, SentrySDK.crashedLastRun) + XCTAssertEqual(SentrySDKInternal.crashHandlerDetectedCrash, SentrySDK.crashedLastRun) } #endif @@ -264,17 +264,8 @@ class SentrySDKTests: XCTestCase { func testLastRunStatus_whenCrashStateLoadedAndCrashed_shouldReturnDidCrash() { // -- Arrange -- - #if ENABLE_KSCRASH - let installer = MockKSCrashInstaller() - installer.crashedLastLaunch = true - let dependencies = MockKSCrashDependencies(installer: installer) - _ = SentryKSCrash.Integration(with: .init(), dependencies: dependencies) - #else SentrySDKInternal.crashReporterInstalled = true - let crashWrapper = TestSentryCrashWrapper(processInfoWrapper: ProcessInfo.processInfo) - crashWrapper.internalCrashedLastLaunch = true - SentryDependencyContainer.sharedInstance().crashWrapper = crashWrapper - #endif + SentrySDKInternal.crashHandlerDetectedCrash = true // -- Act -- let status = SentrySDK.lastRunStatus From 440b482e82c1b72e1b46107a382c8afa304aa23b Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Thu, 23 Jul 2026 10:43:14 +0200 Subject: [PATCH 18/33] chore: sanitize file path in same way as SentryCrash (also fix name in tests) --- .../Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift | 3 ++- .../Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift index ae7eaa3264..188a9756c9 100644 --- a/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift +++ b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift @@ -33,9 +33,10 @@ extension SentryKSCrash { super.init() // To match KSCrash & SentryCrash, we need to add 'KSCrash/' to the cacheDirectoryPath + let bundleName = Bundle.main.infoDictionary?["CFBundleName"] as? String ?? "Unknown" let installPath = URL(fileURLWithPath: options.cacheDirectoryPath) .appendingPathComponent("KSCrash") - .appendingPathComponent(Bundle.main.infoDictionary?["CFBundleName"] as? String ?? "Unknown") + .appendingPathComponent(bundleName.replacingOccurrences(of: "/", with: "-")) .absoluteURL do { diff --git a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift index 4717885ecd..9e6dc7583d 100644 --- a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift +++ b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift @@ -26,7 +26,7 @@ class SentryKSCrashIntegrationTests: XCTestCase { let installer = MockKSCrashInstaller() let deps = MockKSCrashDependencies(installer: installer) let options = makeOptions() - let bundleID = Bundle.main.infoDictionary?["CFBundleIdentifier"] as? String ?? "Unknown" + let bundleID = Bundle.main.infoDictionary?["CFBundleName"] as? String ?? "Unknown" let expectedPath = URL(fileURLWithPath: options.cacheDirectoryPath) .appendingPathComponent("KSCrash") .appendingPathComponent(bundleID) From a394bc20e06eec61fa6870f2e1caec5d219a7fc3 Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Thu, 23 Jul 2026 14:29:53 +0200 Subject: [PATCH 19/33] test: reset state after tests --- SentryTestUtils/Sources/ClearTestState.swift | 1 + .../KSCrash/SentryKSCrash+IntegrationTests.swift | 12 ++++++++++-- Tests/SentryTests/SentrySDKTests.swift | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/SentryTestUtils/Sources/ClearTestState.swift b/SentryTestUtils/Sources/ClearTestState.swift index e5fbc4e029..434bae240f 100644 --- a/SentryTestUtils/Sources/ClearTestState.swift +++ b/SentryTestUtils/Sources/ClearTestState.swift @@ -26,6 +26,7 @@ class TestCleanup: NSObject { SentrySDKInternal.setCurrentHub(nil) SentrySDKInternal.lastRunStatusCalled = false SentrySDKInternal.crashReporterInstalled = false + SentrySDKInternal.crashHandlerDetectedCrash = false SentrySDKInternal.fatalDetected = false SentrySDKInternal.startInvocations = 0 SentrySDKInternal.setDetectedStartUpCrash(false) diff --git a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift index 9e6dc7583d..67f5ca0911 100644 --- a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift +++ b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift @@ -4,6 +4,16 @@ import XCTest class SentryKSCrashIntegrationTests: XCTestCase { + override func setUpWithError() throws { + try super.setUpWithError() + clearTestState() + } + + override func tearDown() { + super.tearDown() + clearTestState() + } + private func makeOptions(enableCrashHandler: Bool = true) -> Options { let options = Options() options.enableCrashHandler = enableCrashHandler @@ -68,8 +78,6 @@ class SentryKSCrashIntegrationTests: XCTestCase { installer.crashedLastLaunch = true let deps = MockKSCrashDependencies(installer: installer) - SentrySDKInternal.fatalDetected = false - SentrySDKInternal.crashReporterInstalled = false _ = SentryKSCrash.Integration(with: makeOptions(), dependencies: deps) XCTAssertEqual(SentrySDK.lastRunStatus, .didCrash) diff --git a/Tests/SentryTests/SentrySDKTests.swift b/Tests/SentryTests/SentrySDKTests.swift index 598f383d4b..281e02710f 100644 --- a/Tests/SentryTests/SentrySDKTests.swift +++ b/Tests/SentryTests/SentrySDKTests.swift @@ -253,7 +253,7 @@ class SentrySDKTests: XCTestCase { func testLastRunStatus_whenCrashStateLoadedAndNoCrash_shouldReturnDidNotCrash() { // -- Arrange -- SentrySDKInternal.crashReporterInstalled = true - // The default test crash reporter returns false for crashedLastLaunch + SentrySDKInternal.crashHandlerDetectedCrash = false // -- Act -- let status = SentrySDK.lastRunStatus From 57c91fa1937ec60c860866456a9f8028a0bc7029 Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Fri, 24 Jul 2026 11:37:35 +0200 Subject: [PATCH 20/33] lint: appease the linting god for the rules hath changed under me --- .../Swift/Integrations/KSCrash/SentryKSCrash+Installer.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Installer.swift b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Installer.swift index a55e6fab4c..36b60ae4e8 100644 --- a/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Installer.swift +++ b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Installer.swift @@ -1,5 +1,5 @@ #if ENABLE_KSCRASH -@_implementationOnly import KSCrashInstallations +internal import KSCrashInstallations extension SentryKSCrash { protocol Installing { From 207f6c18fc0b62815474db2f8fe1820e0927997d Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Fri, 24 Jul 2026 12:01:46 +0200 Subject: [PATCH 21/33] lint: disable the rule (fixed in a follow up PR) as the semantics of internal imports are different than @_implementationOnly --- .../Swift/Integrations/KSCrash/SentryKSCrash+Installer.swift | 3 ++- .../Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Installer.swift b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Installer.swift index 36b60ae4e8..516cf7c3ea 100644 --- a/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Installer.swift +++ b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Installer.swift @@ -1,5 +1,6 @@ #if ENABLE_KSCRASH -internal import KSCrashInstallations +// swiftlint:disable:next no_implementation_only_import +@_implementationOnly import KSCrashInstallations extension SentryKSCrash { protocol Installing { diff --git a/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift index 188a9756c9..5c6647c16f 100644 --- a/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift +++ b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift @@ -1,6 +1,7 @@ #if ENABLE_KSCRASH +// swiftlint:disable:next no_implementation_only_import +@_implementationOnly import KSCrashInstallations internal import _SentryPrivate -internal import KSCrashInstallations import Foundation // MARK: - Integration From d975839a5771a9c7c1df3c4735df69132ed62c68 Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Wed, 29 Jul 2026 15:04:28 +0200 Subject: [PATCH 22/33] chore: extract install path to helper function to centralize path building logic add tests to cover the happy and unhappy paths --- .../KSCrash/SentryKSCrash+Integration.swift | 20 ++++++--- .../SentryKSCrash+IntegrationTests.swift | 43 ++++++++++++++++--- 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift index 5c6647c16f..31eb693cd3 100644 --- a/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift +++ b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift @@ -34,11 +34,7 @@ extension SentryKSCrash { super.init() // To match KSCrash & SentryCrash, we need to add 'KSCrash/' to the cacheDirectoryPath - let bundleName = Bundle.main.infoDictionary?["CFBundleName"] as? String ?? "Unknown" - let installPath = URL(fileURLWithPath: options.cacheDirectoryPath) - .appendingPathComponent("KSCrash") - .appendingPathComponent(bundleName.replacingOccurrences(of: "/", with: "-")) - .absoluteURL + let installPath = Self.installPath(for: options.cacheDirectoryPath, bundleInfo: Bundle.main.infoDictionary) do { try dependencies.kscrashInstaller.install( @@ -64,6 +60,20 @@ extension SentryKSCrash { } func uninstall() {} + + // MARK: - Helpers + + /// Builds the KSCrash install path by appending `KSCrash/` to + /// `cacheDirectoryPath`, matching the layout used by KSCrash and SentryCrash. + /// `CFBundleName` is sanitized by replacing `/` with `-` so it is safe as a + /// single path component. Falls back to `"Unknown"` if the key is absent. + static func installPath(for cacheDirectoryPath: String, bundleInfo: [String: Any]?) -> URL { + let bundleName = bundleInfo?["CFBundleName"] as? String ?? "Unknown" + return URL(fileURLWithPath: cacheDirectoryPath) + .appendingPathComponent("KSCrash") + .appendingPathComponent(bundleName.replacingOccurrences(of: "/", with: "-")) + .absoluteURL + } } } #endif diff --git a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift index 67f5ca0911..9eaf13e34e 100644 --- a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift +++ b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift @@ -36,17 +36,50 @@ class SentryKSCrashIntegrationTests: XCTestCase { let installer = MockKSCrashInstaller() let deps = MockKSCrashDependencies(installer: installer) let options = makeOptions() - let bundleID = Bundle.main.infoDictionary?["CFBundleName"] as? String ?? "Unknown" - let expectedPath = URL(fileURLWithPath: options.cacheDirectoryPath) - .appendingPathComponent("KSCrash") - .appendingPathComponent(bundleID) - .path + let expectedPath = SentryKSCrash.Integration.installPath( + for: options.cacheDirectoryPath, + bundleInfo: Bundle.main.infoDictionary + ).path _ = SentryKSCrash.Integration(with: options, dependencies: deps) XCTAssertEqual(installer.installCalls[0].installPath, expectedPath) } + // MARK: - installPath helper + + func testInstallPath_appendsKSCrashAndBundleName() { + let result = SentryKSCrash.Integration.installPath( + for: "/var/mobile/Containers/Data/app", + bundleInfo: ["CFBundleName": "MyApp"] + ) + XCTAssertEqual(result.path, "/var/mobile/Containers/Data/app/KSCrash/MyApp") + } + + func testInstallPath_sanitizesSlashesInBundleName() { + let result = SentryKSCrash.Integration.installPath( + for: "/cache", + bundleInfo: ["CFBundleName": "My/App/Name"] + ) + XCTAssertEqual(result.path, "/cache/KSCrash/My-App-Name") + } + + func testInstallPath_fallsBackToUnknown_whenBundleInfoIsNil() { + let result = SentryKSCrash.Integration.installPath( + for: "/cache", + bundleInfo: nil + ) + XCTAssertEqual(result.path, "/cache/KSCrash/Unknown") + } + + func testInstallPath_fallsBackToUnknown_whenCFBundleNameKeyMissing() { + let result = SentryKSCrash.Integration.installPath( + for: "/cache", + bundleInfo: ["CFBundleIdentifier": "com.example.app"] + ) + XCTAssertEqual(result.path, "/cache/KSCrash/Unknown") + } + func testInstall_whenCrashedLastLaunch_shouldSetFatalDetected() { let installer = MockKSCrashInstaller() installer.crashedLastLaunch = true From 0c228c3801eea260dc3ca25a4e074bd8fe1ac69c Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Wed, 29 Jul 2026 15:09:58 +0200 Subject: [PATCH 23/33] ref: extract installer to local variable --- .../Integrations/KSCrash/SentryKSCrash+Integration.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift index 31eb693cd3..d09987c6b7 100644 --- a/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift +++ b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift @@ -33,11 +33,13 @@ extension SentryKSCrash { self.options = options super.init() + let installer = dependencies.kscrashInstaller + // To match KSCrash & SentryCrash, we need to add 'KSCrash/' to the cacheDirectoryPath let installPath = Self.installPath(for: options.cacheDirectoryPath, bundleInfo: Bundle.main.infoDictionary) do { - try dependencies.kscrashInstaller.install( + try installer.install( installPath: installPath.path, monitors: productionSafeMonitors, enableSwapCxaThrow: options.experimental.enableUnhandledCPPExceptionsV2 @@ -48,7 +50,7 @@ extension SentryKSCrash { } SentrySDKInternal.crashReporterInstalled = true - if dependencies.kscrashInstaller.crashedLastLaunch { + if installer.crashedLastLaunch { SentrySDKInternal.fatalDetected = true SentrySDKInternal.crashHandlerDetectedCrash = true } From a23914d447eeda6317aabc0c9894d071ee377b41 Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Fri, 31 Jul 2026 08:17:11 +0200 Subject: [PATCH 24/33] test: don't clear test state on setup & tear down --- .../Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift index 9eaf13e34e..57366d9733 100644 --- a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift +++ b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift @@ -6,12 +6,10 @@ import XCTest class SentryKSCrashIntegrationTests: XCTestCase { override func setUpWithError() throws { try super.setUpWithError() - clearTestState() } override func tearDown() { super.tearDown() - clearTestState() } private func makeOptions(enableCrashHandler: Bool = true) -> Options { From 2290319cac43a71637340d4f070cf542679517c9 Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Fri, 31 Jul 2026 08:23:12 +0200 Subject: [PATCH 25/33] chore: track installed status at the crash subsystem level --- Sources/Sentry/include/SentryCrash.h | 2 ++ Sources/SentryCrash/Recording/SentryCrash.m | 3 +++ .../Integrations/KSCrash/SentryKSCrash+Installer.swift | 7 +++++++ Sources/Swift/SentryCrash/SentryCrashReporter.swift | 1 + Sources/Swift/SentryCrash/SentryCrashSwift.swift | 6 +++++- Sources/Swift/SentryCrash/SentryDefaultCrashReporter.swift | 5 +++++ .../Integrations/KSCrash/MockKSCrashInstaller.swift | 2 ++ .../SentrySessionReplayIntegrationTests.swift | 1 + Tests/SentryTests/SentryCrash/TestSentryCrashWrapper.swift | 2 ++ 9 files changed, 28 insertions(+), 1 deletion(-) diff --git a/Sources/Sentry/include/SentryCrash.h b/Sources/Sentry/include/SentryCrash.h index a5a0489cb6..b6e28062c6 100644 --- a/Sources/Sentry/include/SentryCrash.h +++ b/Sources/Sentry/include/SentryCrash.h @@ -189,6 +189,8 @@ SENTRY_NO_INIT /** If true, the application crashed on the previous launch. */ @property (nonatomic, readonly, assign) BOOL crashedLastLaunch; +@property (nonatomic, readonly) BOOL installed; + /** The total number of unsent reports. Note: This is an expensive operation. */ @property (nonatomic, readonly, assign) int reportCount; diff --git a/Sources/SentryCrash/Recording/SentryCrash.m b/Sources/SentryCrash/Recording/SentryCrash.m index 9d33b20709..c0d6427071 100644 --- a/Sources/SentryCrash/Recording/SentryCrash.m +++ b/Sources/SentryCrash/Recording/SentryCrash.m @@ -290,6 +290,8 @@ - (BOOL)install object:nil]; #endif // SENTRY_HAS_NSEXTENSION + _installed = true; + return true; } @@ -300,6 +302,7 @@ - (void)uninstall self.monitoringFromUninstalledToRestore = YES; self.onCrash = NULL; sentrycrash_uninstall(); + _installed = false; #if SENTRY_HAS_UIKIT id notificationCenter diff --git a/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Installer.swift b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Installer.swift index 516cf7c3ea..a3312e536e 100644 --- a/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Installer.swift +++ b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Installer.swift @@ -14,6 +14,8 @@ extension SentryKSCrash { /// Whether the previous run crashed. var crashedLastLaunch: Bool { get } + + var installed: Bool { get } } /// Configures and installs a crash handler @@ -35,6 +37,11 @@ extension SentryKSCrash { } var crashedLastLaunch: Bool { KSCrash.shared.crashedLastLaunch } + + var installed: Bool { + // From KSCrash docs: 'If the crash reporter is not installed, this will be `nil`' + KSCrash.shared.reportStore != nil + } } } #endif diff --git a/Sources/Swift/SentryCrash/SentryCrashReporter.swift b/Sources/Swift/SentryCrash/SentryCrashReporter.swift index f20fc8566d..b077e41bc9 100644 --- a/Sources/Swift/SentryCrash/SentryCrashReporter.swift +++ b/Sources/Swift/SentryCrash/SentryCrashReporter.swift @@ -2,6 +2,7 @@ import Foundation @_spi(Private) @objc public protocol SentryCrashReporter: NSObjectProtocol { + @objc var installed: Bool { get } @objc var crashedLastLaunch: Bool { get } @objc var durationFromCrashStateInitToLastCrash: TimeInterval { get } @objc var activeDurationSinceLastCrash: TimeInterval { get } diff --git a/Sources/Swift/SentryCrash/SentryCrashSwift.swift b/Sources/Swift/SentryCrash/SentryCrashSwift.swift index 6e1965231e..c1ac019a9a 100644 --- a/Sources/Swift/SentryCrash/SentryCrashSwift.swift +++ b/Sources/Swift/SentryCrash/SentryCrashSwift.swift @@ -73,7 +73,11 @@ private final class CrashReportFilterBridge: NSObject, SentryCrashReportFilter { @objc public func install() { sentryCrash.install() } - + + @objc public var installed: Bool { + sentryCrash.installed + } + @objc public func uninstall() { sentryCrash.uninstall() } diff --git a/Sources/Swift/SentryCrash/SentryDefaultCrashReporter.swift b/Sources/Swift/SentryCrash/SentryDefaultCrashReporter.swift index a100564790..38d1ec4577 100644 --- a/Sources/Swift/SentryCrash/SentryDefaultCrashReporter.swift +++ b/Sources/Swift/SentryCrash/SentryDefaultCrashReporter.swift @@ -73,6 +73,11 @@ public final class SentryDefaultCrashReporter: NSObject, SentryCrashReporter { sentrycrashbic_stopCache() } + @objc + public var installed: Bool { + return bridge.crashReporter.installed + } + @objc public var crashedLastLaunch: Bool { return bridge.crashReporter.crashedLastLaunch diff --git a/Tests/SentryTests/Integrations/KSCrash/MockKSCrashInstaller.swift b/Tests/SentryTests/Integrations/KSCrash/MockKSCrashInstaller.swift index 4e59fdd1a5..7651833f11 100644 --- a/Tests/SentryTests/Integrations/KSCrash/MockKSCrashInstaller.swift +++ b/Tests/SentryTests/Integrations/KSCrash/MockKSCrashInstaller.swift @@ -13,12 +13,14 @@ final class MockKSCrashInstaller: SentryKSCrash.Installing { public var installCalls: [(installPath: String, monitors: UInt, enableSwapCxaThrow: Bool)] = [] public var shouldThrow: Error? public var crashedLastLaunch: Bool = false + public var installed: Bool = false public init() {} public func install(installPath: String, monitors: UInt, enableSwapCxaThrow: Bool) throws { installCalls.append((installPath: installPath, monitors: monitors, enableSwapCxaThrow: enableSwapCxaThrow)) if let error = shouldThrow { throw error } + installed = true } } #endif diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift index 6f9a84485d..36e9310abf 100644 --- a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift +++ b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift @@ -27,6 +27,7 @@ class SentrySessionReplayIntegrationTests: XCTestCase { super.init() } + var installed: Bool { false } var crashedLastLaunch: Bool { false } var durationFromCrashStateInitToLastCrash: TimeInterval { 0 } var activeDurationSinceLastCrash: TimeInterval { 0 } diff --git a/Tests/SentryTests/SentryCrash/TestSentryCrashWrapper.swift b/Tests/SentryTests/SentryCrash/TestSentryCrashWrapper.swift index 6905a681f5..4e0c21a1f4 100644 --- a/Tests/SentryTests/SentryCrash/TestSentryCrashWrapper.swift +++ b/Tests/SentryTests/SentryCrash/TestSentryCrashWrapper.swift @@ -7,6 +7,7 @@ class TestSentryCrashReporter: NSObject, SentryCrashReporter { // MARK: - Test Properties + var internalInstalled = false var internalCrashedLastLaunch = false var internalDurationFromCrashStateInitToLastCrash: TimeInterval = 0 var internalActiveDurationSinceLastCrash: TimeInterval = 0 @@ -31,6 +32,7 @@ class TestSentryCrashReporter: NSObject, SentryCrashReporter { // MARK: - SentryCrashReporter Protocol + var installed: Bool { internalInstalled } var crashedLastLaunch: Bool { internalCrashedLastLaunch } var durationFromCrashStateInitToLastCrash: TimeInterval { internalDurationFromCrashStateInitToLastCrash } var activeDurationSinceLastCrash: TimeInterval { internalActiveDurationSinceLastCrash } From f37a2794fc3d292f15112523cf2520ad5d124acf Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Fri, 31 Jul 2026 08:26:32 +0200 Subject: [PATCH 26/33] feat: Add SentryKSCrash+Query to expose crash handler state (swift) to ObjC (SDKInternal) --- SentryTestUtils/Sources/ClearTestState.swift | 2 - Sources/Sentry/SentrySDKInternal.m | 51 ++++++++++--------- Sources/Sentry/include/SentrySDK+Private.h | 8 --- .../KSCrash/SentryKSCrash+Integration.swift | 11 ++-- .../KSCrash/SentryKSCrash+Query.swift | 46 +++++++++++++++++ .../Integrations/KSCrash/SentryKSCrash.swift | 4 +- .../SentryCrash/SentryCrashIntegration.swift | 2 - Sources/Swift/SentryDependencyContainer.swift | 22 ++++++-- 8 files changed, 95 insertions(+), 51 deletions(-) create mode 100644 Sources/Swift/Integrations/KSCrash/SentryKSCrash+Query.swift diff --git a/SentryTestUtils/Sources/ClearTestState.swift b/SentryTestUtils/Sources/ClearTestState.swift index 434bae240f..29c300b0b4 100644 --- a/SentryTestUtils/Sources/ClearTestState.swift +++ b/SentryTestUtils/Sources/ClearTestState.swift @@ -25,8 +25,6 @@ class TestCleanup: NSObject { SentrySDK.close() SentrySDKInternal.setCurrentHub(nil) SentrySDKInternal.lastRunStatusCalled = false - SentrySDKInternal.crashReporterInstalled = false - SentrySDKInternal.crashHandlerDetectedCrash = false SentrySDKInternal.fatalDetected = false SentrySDKInternal.startInvocations = 0 SentrySDKInternal.setDetectedStartUpCrash(false) diff --git a/Sources/Sentry/SentrySDKInternal.m b/Sources/Sentry/SentrySDKInternal.m index b39717e705..8bf23cc1f5 100644 --- a/Sources/Sentry/SentrySDKInternal.m +++ b/Sources/Sentry/SentrySDKInternal.m @@ -17,6 +17,14 @@ #import "SentrySwift.h" #import "SentryTransactionContext.h" +#if ENABLE_KSCRASH +// Forward declared from SentryKSCrash+Query.swift +@interface SentryKSCrashQuery +@property (nonatomic, readonly) BOOL installed; +@property (nonatomic, readonly) BOOL crashedLastLaunch; +@end +#endif // ENABLE_KSCRASH + #if TARGET_OS_OSX # import "SentryCrashExceptionApplication.h" #endif // TARGET_OS_MAC @@ -38,9 +46,7 @@ @implementation SentrySDKInternal static SentryHubInternal *_Nullable currentHub; static NSObject *currentHubLock; static BOOL lastRunStatusCalled; -static BOOL crashReporterInstalled; static BOOL fatalDetected; -static BOOL crashHandlerDetectedCrash; static SentryAppStartMeasurement *_Nullable sentrySDKappStartMeasurement; static NSObject *sentrySDKappStartMeasurementLock; static BOOL _detectedStartUpCrash; @@ -144,12 +150,11 @@ + (void)setLastRunStatusCalled:(BOOL)value + (BOOL)crashReporterInstalled { - return crashReporterInstalled; -} - -+ (void)setCrashReporterInstalled:(BOOL)value -{ - crashReporterInstalled = value; +#if ENABLE_KSCRASH + return SentryDependencyContainer.sharedInstance.kscrashQuery.installed; +#else + return SentryDependencyContainer.sharedInstance.crashWrapper.installed; +#endif } + (BOOL)fatalDetected @@ -162,16 +167,6 @@ + (void)setFatalDetected:(BOOL)value fatalDetected = value; } -+ (BOOL)crashHandlerDetectedCrash -{ - return crashHandlerDetectedCrash; -} - -+ (void)setCrashHandlerDetectedCrash:(BOOL)value -{ - crashHandlerDetectedCrash = value; -} - /** * Not public, only for internal use. */ @@ -287,7 +282,7 @@ + (void)startWithOptions:(SentryOptions *)options // The .didCrash case is handled by SentryClient.prepareEvent when // a fatal event arrives. Here we report .didNotCrash if no // integration set fatalDetected during init. - if (crashReporterInstalled && !fatalDetected) { + if ([SentrySDKInternal crashReporterInstalled] && !fatalDetected) { lastRunStatusCalled = YES; if (nil != options.onLastRunStatusDetermined) { options.onLastRunStatusDetermined(SentryLastRunStatusDidNotCrash, nil); @@ -508,15 +503,25 @@ + (void)setUser:(SentryUser *_Nullable)user + (BOOL)crashedLastRun { - return crashHandlerDetectedCrash; + return SentryDependencyContainer.sharedInstance.crashReporter.crashedLastLaunch; } + (NSInteger)lastRunStatus { - if (!crashReporterInstalled) { + if (![SentrySDKInternal crashReporterInstalled]) { return SentryLastRunStatusUnknown; } - return crashHandlerDetectedCrash ? SentryLastRunStatusDidCrash : SentryLastRunStatusDidNotCrash; + +#if ENABLE_KSCRASH + if (SentryDependencyContainer.sharedInstance.kscrashQuery.crashedLastLaunch) { + return SentryLastRunStatusDidCrash; + } +#else + if (SentryDependencyContainer.sharedInstance.crashWrapper.crashedLastLaunch) { + return SentryLastRunStatusDidCrash; + } +#endif + return SentryLastRunStatusDidNotCrash; } + (BOOL)detectedStartUpCrash @@ -612,9 +617,7 @@ + (void)close [SentrySDKInternal setCurrentHub:nil]; - crashReporterInstalled = NO; fatalDetected = NO; - crashHandlerDetectedCrash = NO; lastRunStatusCalled = NO; [SentryDependencyContainer.sharedInstance.crashWrapper stopBinaryImageCache]; diff --git a/Sources/Sentry/include/SentrySDK+Private.h b/Sources/Sentry/include/SentrySDK+Private.h index b8b640026c..0aabe0f679 100644 --- a/Sources/Sentry/include/SentrySDK+Private.h +++ b/Sources/Sentry/include/SentrySDK+Private.h @@ -49,14 +49,6 @@ NS_ASSUME_NONNULL_BEGIN */ @property (nonatomic, class) BOOL fatalDetected; -/** - * Set to @c YES by the crash handler integration (SentryCrash or KSCrash) when the - * crash reporter reports a crash on the last launch. Unlike @c fatalDetected, this is - * never set by watchdog termination detection, so it accurately reflects whether a - * true crash (as opposed to an OS-level termination) occurred. - */ -@property (nonatomic, class) BOOL crashHandlerDetectedCrash; - + (void)setDetectedStartUpCrash:(BOOL)value; + (void)setAppStartMeasurement:(nullable SentryAppStartMeasurement *)appStartMeasurement; diff --git a/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift index d09987c6b7..27f471f74f 100644 --- a/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift +++ b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift @@ -33,7 +33,7 @@ extension SentryKSCrash { self.options = options super.init() - let installer = dependencies.kscrashInstaller + let installer = dependencies.getKSCrashInstaller() // To match KSCrash & SentryCrash, we need to add 'KSCrash/' to the cacheDirectoryPath let installPath = Self.installPath(for: options.cacheDirectoryPath, bundleInfo: Bundle.main.infoDictionary) @@ -49,10 +49,8 @@ extension SentryKSCrash { return nil } - SentrySDKInternal.crashReporterInstalled = true if installer.crashedLastLaunch { SentrySDKInternal.fatalDetected = true - SentrySDKInternal.crashHandlerDetectedCrash = true } } @@ -64,11 +62,8 @@ extension SentryKSCrash { func uninstall() {} // MARK: - Helpers - - /// Builds the KSCrash install path by appending `KSCrash/` to - /// `cacheDirectoryPath`, matching the layout used by KSCrash and SentryCrash. - /// `CFBundleName` is sanitized by replacing `/` with `-` so it is safe as a - /// single path component. Falls back to `"Unknown"` if the key is absent. + /// Get the install path for the crash reporter + /// This method exists to centralize path building to save rebuilding it manually in tests static func installPath(for cacheDirectoryPath: String, bundleInfo: [String: Any]?) -> URL { let bundleName = bundleInfo?["CFBundleName"] as? String ?? "Unknown" return URL(fileURLWithPath: cacheDirectoryPath) diff --git a/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Query.swift b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Query.swift new file mode 100644 index 0000000000..ceed8a7b33 --- /dev/null +++ b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Query.swift @@ -0,0 +1,46 @@ +#if ENABLE_KSCRASH +// swiftlint:disable:next no_implementation_only_import +@_implementationOnly import KSCrashInstallations +internal import _SentryPrivate + +extension SentryKSCrash { + protocol QueryProvider { + associatedtype Querying: SentryKSCrash.Querying + + var kscrashQuery: Querying { get } + } +} + +extension SentryKSCrash { + /// Allows querying the state of the crash reporter + @objc(SentryKSCrashQuerying) + public protocol Querying: NSObjectProtocol { + /// Whether KSCrash has been successfully installed this session. + @objc var installed: Bool { get } + /// Whether KSCrash crashed on the previous launch. + @objc var crashedLastLaunch: Bool { get } + } +} + +extension SentryKSCrash { + /// An ObjC-accessible class that surfaces live KSCrash crash state to ObjC callers + /// without adding `@objc` to `SentryKSCrash.Installer`. + @_spi(Private) + @objc(SentryKSCrashQuery) + final public class Query: NSObject, Querying { + private let installer: any Installing + + init(installer: some Installing) { + self.installer = installer + } + + // swiftlint:disable missing_docs + @objc + public var installed: Bool { installer.installed } + + @objc + public var crashedLastLaunch: Bool { installer.crashedLastLaunch } + //swiftlint:enable missing_docs + } +} +#endif diff --git a/Sources/Swift/Integrations/KSCrash/SentryKSCrash.swift b/Sources/Swift/Integrations/KSCrash/SentryKSCrash.swift index 8c198cedc0..3e19c75a2d 100644 --- a/Sources/Swift/Integrations/KSCrash/SentryKSCrash.swift +++ b/Sources/Swift/Integrations/KSCrash/SentryKSCrash.swift @@ -1,12 +1,12 @@ #if ENABLE_KSCRASH /// All types related to KSCrash should live under this type -enum SentryKSCrash { +@_spi(Private) public enum SentryKSCrash { /// Provides a `KSCrashInstalling` instance for dependency injection. protocol InstallerProvider { /// The installer used to set up KSCrash crash reporting. associatedtype Installing: SentryKSCrash.Installing - var kscrashInstaller: Installing { get } + func getKSCrashInstaller() -> Installing } } #endif diff --git a/Sources/Swift/Integrations/SentryCrash/SentryCrashIntegration.swift b/Sources/Swift/Integrations/SentryCrash/SentryCrashIntegration.swift index 858ec7f3fe..543ce67e96 100644 --- a/Sources/Swift/Integrations/SentryCrash/SentryCrashIntegration.swift +++ b/Sources/Swift/Integrations/SentryCrash/SentryCrashIntegration.swift @@ -156,10 +156,8 @@ final class SentryCrashIntegration: NSOb // SentrySDK.lastRunStatus returns a definitive answer and the integration // installer can determine the .didNotCrash case. We can't use // isIntegrationInstalled because it's set after init returns. - SentrySDKInternal.crashReporterInstalled = true if SentryDependencyContainer.sharedInstance().crashWrapper.crashedLastLaunch { SentrySDKInternal.fatalDetected = true - SentrySDKInternal.crashHandlerDetectedCrash = true } #if os(macOS) && !SENTRY_NO_UI_FRAMEWORK diff --git a/Sources/Swift/SentryDependencyContainer.swift b/Sources/Swift/SentryDependencyContainer.swift index 3e876670ac..5df656f8aa 100644 --- a/Sources/Swift/SentryDependencyContainer.swift +++ b/Sources/Swift/SentryDependencyContainer.swift @@ -440,6 +440,21 @@ extension SentryFileManager: SentryFileManagerProtocol { } processInfoWrapper: processInfoWrapper ) } + +#if ENABLE_KSCRASH + private var kscrashInstaller: SentryKSCrash.Installer? + func getKSCrashInstaller() -> SentryKSCrash.Installer { + getLazyVar(\.kscrashInstaller) { + SentryKSCrash.Installer() + } + } + + private var _kscrashQuery: SentryKSCrash.Query? + @objc public lazy var kscrashQuery: SentryKSCrash.Query = getLazyVar(\._kscrashQuery) { + SentryKSCrash.Query(installer: getKSCrashInstaller()) + } + +#endif } // swiftlint:enable type_body_length @@ -520,11 +535,8 @@ extension SentryDependencyContainer: DateProviderProvider {} extension SentryDependencyContainer: AutoSessionTrackingProvider { } #if ENABLE_KSCRASH -extension SentryDependencyContainer: SentryKSCrash.InstallerProvider { - var kscrashInstaller: SentryKSCrash.Installer { - SentryKSCrash.Installer() - } -} +extension SentryDependencyContainer: SentryKSCrash.InstallerProvider {} +extension SentryDependencyContainer: SentryKSCrash.QueryProvider {} #endif protocol FileIOTrackerProvider { From eb0fd1799aae91b0d5f142368e5956b039f17134 Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Thu, 30 Jul 2026 08:56:12 +0200 Subject: [PATCH 27/33] test: update tests to use new SentryKSCrash & SDKInternal API --- .../KSCrash/MockKSCrashInstaller.swift | 6 ++ .../KSCrash/MockKSCrashQuery.swift | 16 ++++ .../SentryKSCrash+IntegrationTests.swift | 79 +++++++++++++------ Tests/SentryTests/SentrySDKTests.swift | 37 +++++++-- 4 files changed, 106 insertions(+), 32 deletions(-) create mode 100644 Tests/SentryTests/Integrations/KSCrash/MockKSCrashQuery.swift diff --git a/Tests/SentryTests/Integrations/KSCrash/MockKSCrashInstaller.swift b/Tests/SentryTests/Integrations/KSCrash/MockKSCrashInstaller.swift index 7651833f11..e9ec365d4e 100644 --- a/Tests/SentryTests/Integrations/KSCrash/MockKSCrashInstaller.swift +++ b/Tests/SentryTests/Integrations/KSCrash/MockKSCrashInstaller.swift @@ -2,11 +2,17 @@ @_spi(Private) @testable import Sentry final class MockKSCrashDependencies: SentryKSCrash.DependencyProvider { + typealias Installing = MockKSCrashInstaller + let kscrashInstaller: MockKSCrashInstaller init(installer: MockKSCrashInstaller = .init()) { self.kscrashInstaller = installer } + + func getKSCrashInstaller() -> MockKSCrashInstaller { + return kscrashInstaller + } } final class MockKSCrashInstaller: SentryKSCrash.Installing { diff --git a/Tests/SentryTests/Integrations/KSCrash/MockKSCrashQuery.swift b/Tests/SentryTests/Integrations/KSCrash/MockKSCrashQuery.swift new file mode 100644 index 0000000000..3fb310d566 --- /dev/null +++ b/Tests/SentryTests/Integrations/KSCrash/MockKSCrashQuery.swift @@ -0,0 +1,16 @@ +#if ENABLE_KSCRASH +@_spi(Private) @testable import Sentry + +enum MockKSCrashQuery { + static func create( + installed: Bool = false, + crashedLastLaunch: Bool = false + ) -> SentryKSCrash.Query { + let mockInstaller = MockKSCrashInstaller() + mockInstaller.installed = installed + mockInstaller.crashedLastLaunch = crashedLastLaunch + + return SentryKSCrash.Query(installer: mockInstaller) + } +} +#endif diff --git a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift index 57366d9733..150595b160 100644 --- a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift +++ b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift @@ -19,18 +19,22 @@ class SentryKSCrashIntegrationTests: XCTestCase { } func testInstall_whenCrashHandlerEnabled_shouldCallInstallOnce() { + // -- Arrange -- let installer = MockKSCrashInstaller() let deps = MockKSCrashDependencies(installer: installer) let options = makeOptions() + // -- Act -- let sut = SentryKSCrash.Integration(with: options, dependencies: deps) + // -- Assert -- XCTAssertNotNil(sut) XCTAssertEqual(installer.installCalls.count, 1) XCTAssertEqual(installer.installCalls[0].monitors, SentryKSCrash.productionSafeMonitors) } func testInstall_whenCrashHandlerEnabled_shouldAppendKSCrashBundleSubdirectory() { + // -- Arrange -- let installer = MockKSCrashInstaller() let deps = MockKSCrashDependencies(installer: installer) let options = makeOptions() @@ -39,87 +43,112 @@ class SentryKSCrashIntegrationTests: XCTestCase { bundleInfo: Bundle.main.infoDictionary ).path + // -- Act -- _ = SentryKSCrash.Integration(with: options, dependencies: deps) + // -- Assert -- XCTAssertEqual(installer.installCalls[0].installPath, expectedPath) } // MARK: - installPath helper func testInstallPath_appendsKSCrashAndBundleName() { + // -- Arrange -- + let cacheDirectory = "/var/mobile/Containers/Data/app" + let bundleInfo = ["CFBundleName": "MyApp"] + + // -- Act -- let result = SentryKSCrash.Integration.installPath( - for: "/var/mobile/Containers/Data/app", - bundleInfo: ["CFBundleName": "MyApp"] + for: cacheDirectory, + bundleInfo: bundleInfo ) + + // -- Assert -- XCTAssertEqual(result.path, "/var/mobile/Containers/Data/app/KSCrash/MyApp") } func testInstallPath_sanitizesSlashesInBundleName() { + // -- Arrange + let cacheDirectory = "/cache" + let bundleInfo = ["CFBundleName": "My/App/Name"] + + // -- Act -- let result = SentryKSCrash.Integration.installPath( - for: "/cache", - bundleInfo: ["CFBundleName": "My/App/Name"] + for: cacheDirectory, + bundleInfo: bundleInfo ) + + // -- Assert XCTAssertEqual(result.path, "/cache/KSCrash/My-App-Name") } func testInstallPath_fallsBackToUnknown_whenBundleInfoIsNil() { + // -- Arrange -- + let cacheDirectory = "/cache" + let bundleInfo: [String: Any]? = nil + + // -- Act -- let result = SentryKSCrash.Integration.installPath( - for: "/cache", - bundleInfo: nil + for: cacheDirectory, + bundleInfo: bundleInfo ) + + // -- Assert -- XCTAssertEqual(result.path, "/cache/KSCrash/Unknown") } func testInstallPath_fallsBackToUnknown_whenCFBundleNameKeyMissing() { + // -- Arrange + let cacheDirectory = "/cache" + let bundleInfo = ["CFBundleIdentifier": "com.example.app"] + + // -- Act -- let result = SentryKSCrash.Integration.installPath( - for: "/cache", - bundleInfo: ["CFBundleIdentifier": "com.example.app"] + for: cacheDirectory, + bundleInfo: bundleInfo ) - XCTAssertEqual(result.path, "/cache/KSCrash/Unknown") - } - func testInstall_whenCrashedLastLaunch_shouldSetFatalDetected() { - let installer = MockKSCrashInstaller() - installer.crashedLastLaunch = true - let deps = MockKSCrashDependencies(installer: installer) - - SentrySDKInternal.fatalDetected = false - SentrySDKInternal.crashHandlerDetectedCrash = false - let sut = SentryKSCrash.Integration(with: makeOptions(), dependencies: deps) - - XCTAssertNotNil(sut) - XCTAssertTrue(SentrySDKInternal.fatalDetected) - XCTAssertTrue(SentrySDKInternal.crashHandlerDetectedCrash) + // -- Assert -- + XCTAssertEqual(result.path, "/cache/KSCrash/Unknown") } func testInstall_whenInstallThrows_shouldReturnNil() { + // -- Arrange -- let installer = MockKSCrashInstaller() installer.shouldThrow = NSError(domain: "TestKSCrash", code: -99) let deps = MockKSCrashDependencies(installer: installer) + // -- Act -- let sut = SentryKSCrash.Integration(with: makeOptions(), dependencies: deps) + // -- Assert -- XCTAssertNil(sut) } // MARK: - Last-run crash APIs - func testLastRunStatus_whenCrashedLastLaunch_shouldReturnDidCrash() { + func testInstall_whenInstallThrows_shouldNotSetCrashReporterInstalled() { + // -- Arrange let installer = MockKSCrashInstaller() - installer.crashedLastLaunch = true + installer.shouldThrow = NSError(domain: "TestKSCrash", code: -99) let deps = MockKSCrashDependencies(installer: installer) + // -- Act -- _ = SentryKSCrash.Integration(with: makeOptions(), dependencies: deps) - XCTAssertEqual(SentrySDK.lastRunStatus, .didCrash) + // -- Assert -- + XCTAssertFalse(SentrySDKInternal.crashReporterInstalled) } func testInstall_whenCrashHandlerDisabled_shouldSkipInstall() { + // -- Arrange -- let installer = MockKSCrashInstaller() let deps = MockKSCrashDependencies(installer: installer) + // -- Act -- let sut = SentryKSCrash.Integration(with: makeOptions(enableCrashHandler: false), dependencies: deps) + // -- Assert -- XCTAssertNil(sut) XCTAssertEqual(installer.installCalls.count, 0) } diff --git a/Tests/SentryTests/SentrySDKTests.swift b/Tests/SentryTests/SentrySDKTests.swift index 281e02710f..24dd587fcd 100644 --- a/Tests/SentryTests/SentrySDKTests.swift +++ b/Tests/SentryTests/SentrySDKTests.swift @@ -233,7 +233,7 @@ class SentrySDKTests: XCTestCase { #if !SDK_V10 @available(*, deprecated, message: "Testing deprecated crashedLastRun API") func testCrashedLastRun() { - XCTAssertEqual(SentrySDKInternal.crashHandlerDetectedCrash, SentrySDK.crashedLastRun) + XCTAssertEqual(SentryDependencyContainer.sharedInstance().crashReporter.crashedLastLaunch, SentrySDK.crashedLastRun) } #endif @@ -241,7 +241,14 @@ class SentrySDKTests: XCTestCase { func testLastRunStatus_whenCrashStateNotLoaded_shouldReturnUnknown() { // -- Arrange -- - SentrySDKInternal.crashReporterInstalled = false +#if ENABLE_KSCRASH + let mockQuery = MockKSCrashQuery.create(installed: false, crashedLastLaunch: false) + SentryDependencyContainer.sharedInstance().kscrashQuery = mockQuery +#else + let crashWrapper = TestSentryCrashWrapper(processInfoWrapper: ProcessInfo.processInfo) + crashWrapper.internalInstalled = false + SentryDependencyContainer.sharedInstance().crashWrapper = crashWrapper +#endif // -- Act -- let status = SentrySDK.lastRunStatus @@ -252,8 +259,15 @@ class SentrySDKTests: XCTestCase { func testLastRunStatus_whenCrashStateLoadedAndNoCrash_shouldReturnDidNotCrash() { // -- Arrange -- - SentrySDKInternal.crashReporterInstalled = true - SentrySDKInternal.crashHandlerDetectedCrash = false +#if ENABLE_KSCRASH + let mockQuery = MockKSCrashQuery.create(installed: true, crashedLastLaunch: false) + SentryDependencyContainer.sharedInstance().kscrashQuery = mockQuery +#else + let crashWrapper = TestSentryCrashWrapper(processInfoWrapper: ProcessInfo.processInfo) + crashWrapper.internalInstalled = true + crashWrapper.internalCrashedLastLaunch = false + SentryDependencyContainer.sharedInstance().crashWrapper = crashWrapper +#endif // -- Act -- let status = SentrySDK.lastRunStatus @@ -264,8 +278,15 @@ class SentrySDKTests: XCTestCase { func testLastRunStatus_whenCrashStateLoadedAndCrashed_shouldReturnDidCrash() { // -- Arrange -- - SentrySDKInternal.crashReporterInstalled = true - SentrySDKInternal.crashHandlerDetectedCrash = true +#if ENABLE_KSCRASH + let mockQuery = MockKSCrashQuery.create(installed: true, crashedLastLaunch: true) + SentryDependencyContainer.sharedInstance().kscrashQuery = mockQuery +#else + let crashWrapper = TestSentryCrashWrapper(processInfoWrapper: ProcessInfo.processInfo) + crashWrapper.internalInstalled = true + crashWrapper.internalCrashedLastLaunch = true + SentryDependencyContainer.sharedInstance().crashWrapper = crashWrapper +#endif // -- Act -- let status = SentrySDK.lastRunStatus @@ -274,7 +295,9 @@ class SentrySDKTests: XCTestCase { XCTAssertEqual(status, .didCrash) } - func testLastRunStatus_afterClose_shouldReturnUnknown() { + func testLastRunStatus_afterClose_shouldReturnUnknown() throws { + // KSCrash_TODO: SentryKSCrash.Integration currently has no uninstall + try XCTSkipIf(SentryTestSetup.isKSCrashEnabled, "SentryKSCrash.Integration currently doesn't support uninstalling") // -- Arrange -- SentrySDK.start { options in options.dsn = TestConstants.dsnAsString(username: "SentrySDKTests") From 0c27969579e6c5303a9446cd04d30a14616fea30 Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Fri, 31 Jul 2026 09:14:25 +0200 Subject: [PATCH 28/33] test: add test to cover setting fatalDetected --- .../SentryKSCrash+IntegrationTests.swift | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift index 150595b160..880e56077d 100644 --- a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift +++ b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift @@ -9,6 +9,7 @@ class SentryKSCrashIntegrationTests: XCTestCase { } override func tearDown() { + SentrySDKInternal.fatalDetected = false super.tearDown() } @@ -127,6 +128,32 @@ class SentryKSCrashIntegrationTests: XCTestCase { // MARK: - Last-run crash APIs + func testInstall_whenCrashedLastLaunch_shouldSetFatalDetected() { + // -- Arrange -- + let installer = MockKSCrashInstaller() + installer.crashedLastLaunch = true + let deps = MockKSCrashDependencies(installer: installer) + + // -- Act -- + _ = SentryKSCrash.Integration(with: makeOptions(), dependencies: deps) + + // -- Assert -- + XCTAssertTrue(SentrySDKInternal.fatalDetected) + } + + func testInstall_whenDidNotCrashLastLaunch_shouldNotSetFatalDetected() { + // -- Arrange -- + let installer = MockKSCrashInstaller() + installer.crashedLastLaunch = false + let deps = MockKSCrashDependencies(installer: installer) + + // -- Act -- + _ = SentryKSCrash.Integration(with: makeOptions(), dependencies: deps) + + // -- Assert -- + XCTAssertFalse(SentrySDKInternal.fatalDetected) + } + func testInstall_whenInstallThrows_shouldNotSetCrashReporterInstalled() { // -- Arrange let installer = MockKSCrashInstaller() From 3130ed05336739bbe2d2386f5a23ab6ef76b9ed5 Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Fri, 31 Jul 2026 11:47:38 +0200 Subject: [PATCH 29/33] tests: fix and add coverage --- .../SentryKSCrash+IntegrationTests.swift | 5 +- .../KSCrash/SentryKSCrash+QueryTests.swift | 87 +++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+QueryTests.swift diff --git a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift index 880e56077d..682559146e 100644 --- a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift +++ b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift @@ -6,11 +6,12 @@ import XCTest class SentryKSCrashIntegrationTests: XCTestCase { override func setUpWithError() throws { try super.setUpWithError() + SentrySDKInternal.fatalDetected = false } override func tearDown() { - SentrySDKInternal.fatalDetected = false super.tearDown() + SentrySDKInternal.fatalDetected = false } private func makeOptions(enableCrashHandler: Bool = true) -> Options { @@ -118,6 +119,7 @@ class SentryKSCrashIntegrationTests: XCTestCase { let installer = MockKSCrashInstaller() installer.shouldThrow = NSError(domain: "TestKSCrash", code: -99) let deps = MockKSCrashDependencies(installer: installer) + SentryDependencyContainer.sharedInstance().kscrashQuery = SentryKSCrash.Query(installer: installer) // -- Act -- let sut = SentryKSCrash.Integration(with: makeOptions(), dependencies: deps) @@ -159,6 +161,7 @@ class SentryKSCrashIntegrationTests: XCTestCase { let installer = MockKSCrashInstaller() installer.shouldThrow = NSError(domain: "TestKSCrash", code: -99) let deps = MockKSCrashDependencies(installer: installer) + SentryDependencyContainer.sharedInstance().kscrashQuery = SentryKSCrash.Query(installer: installer) // -- Act -- _ = SentryKSCrash.Integration(with: makeOptions(), dependencies: deps) diff --git a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+QueryTests.swift b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+QueryTests.swift new file mode 100644 index 0000000000..08e23b4ec1 --- /dev/null +++ b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+QueryTests.swift @@ -0,0 +1,87 @@ +#if ENABLE_KSCRASH +@_spi(Private) @testable import Sentry +import XCTest + +class SentryKSCrashQueryTests: XCTestCase { + + // MARK: - installed + + func testInstalled_whenInstallerIsInstalled_shouldReturnTrue() { + // -- Arrange -- + let installer = MockKSCrashInstaller() + installer.installed = true + let sut = SentryKSCrash.Query(installer: installer) + + // -- Act -- + let result = sut.installed + + // -- Assert -- + XCTAssertTrue(result) + } + + func testInstalled_whenInstallerIsNotInstalled_shouldReturnFalse() { + // -- Arrange -- + let installer = MockKSCrashInstaller() + installer.installed = false + let sut = SentryKSCrash.Query(installer: installer) + + // -- Act -- + let result = sut.installed + + // -- Assert -- + XCTAssertFalse(result) + } + + func testInstalled_whenInstallerStateChangesAfterInit_shouldReflectCurrentState() { + // -- Arrange -- + let installer = MockKSCrashInstaller() + let sut = SentryKSCrash.Query(installer: installer) + + // -- Act -- + installer.installed = true + + // -- Assert -- + XCTAssertTrue(sut.installed) + } + + // MARK: - crashedLastLaunch + + func testCrashedLastLaunch_whenInstallerCrashedLastLaunch_shouldReturnTrue() { + // -- Arrange -- + let installer = MockKSCrashInstaller() + installer.crashedLastLaunch = true + let sut = SentryKSCrash.Query(installer: installer) + + // -- Act -- + let result = sut.crashedLastLaunch + + // -- Assert -- + XCTAssertTrue(result) + } + + func testCrashedLastLaunch_whenInstallerDidNotCrashLastLaunch_shouldReturnFalse() { + // -- Arrange -- + let installer = MockKSCrashInstaller() + installer.crashedLastLaunch = false + let sut = SentryKSCrash.Query(installer: installer) + + // -- Act -- + let result = sut.crashedLastLaunch + + // -- Assert -- + XCTAssertFalse(result) + } + + func testCrashedLastLaunch_whenInstallerStateChangesAfterInit_shouldReflectCurrentState() { + // -- Arrange -- + let installer = MockKSCrashInstaller() + let sut = SentryKSCrash.Query(installer: installer) + + // -- Act -- + installer.crashedLastLaunch = true + + // -- Assert -- + XCTAssertTrue(sut.crashedLastLaunch) + } +} +#endif From f63e34cc4ec58dcb12a4957cb7fbd552d0d6e698 Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Fri, 31 Jul 2026 15:00:31 +0200 Subject: [PATCH 30/33] chore: add structure for uninstalling (to be implemented in a later PR) --- .../KSCrash/SentryKSCrash+Installer.swift | 24 +++++++++---- .../KSCrash/SentryKSCrash+Integration.swift | 9 +++-- .../KSCrash/MockKSCrashInstaller.swift | 6 ++++ .../SentryKSCrash+IntegrationTests.swift | 34 +++++++++++++++++++ Tests/SentryTests/SentrySDKTests.swift | 5 ++- 5 files changed, 65 insertions(+), 13 deletions(-) diff --git a/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Installer.swift b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Installer.swift index a3312e536e..badf8f2e1b 100644 --- a/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Installer.swift +++ b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Installer.swift @@ -12,14 +12,23 @@ extension SentryKSCrash { /// - Throws: Any error from `KSCrash.installWithConfiguration(_:error:)`. func install(installPath: String, monitors: UInt, enableSwapCxaThrow: Bool) throws + /// Uninstall the crash handler for the current SDK lifecycle. + func uninstall() + /// Whether the previous run crashed. var crashedLastLaunch: Bool { get } + /// Whether this installer has successfully installed for the current SDK lifecycle. + /// + /// Tracked separately from KSCrash's process-lifetime `reportStore`, which stays + /// non-nil after `SentrySDK.close()`. var installed: Bool { get } } - /// Configures and installs a crash handler - struct Installer: SentryKSCrash.Installing { + /// Configures and installs a crash handler. + final class Installer: SentryKSCrash.Installing { + private(set) var installed = false + func install(installPath: String, monitors: UInt, enableSwapCxaThrow: Bool) throws { let config = KSCrashConfiguration() config.installPath = installPath @@ -34,14 +43,15 @@ extension SentryKSCrash { // The crash handler is already running — treat this as success. SentrySDKLog.debug("KSCrash already installed; continuing.") } + installed = true } - var crashedLastLaunch: Bool { KSCrash.shared.crashedLastLaunch } - - var installed: Bool { - // From KSCrash docs: 'If the crash reporter is not installed, this will be `nil`' - KSCrash.shared.reportStore != nil + func uninstall() { + // KSCrash itself cannot be uninstalled in-process (process-lifetime). + installed = false } + + var crashedLastLaunch: Bool { KSCrash.shared.crashedLastLaunch } } } #endif diff --git a/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift index 27f471f74f..5a986b70f5 100644 --- a/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift +++ b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Integration.swift @@ -21,6 +21,7 @@ extension SentryKSCrash { final class Integration: NSObject, SwiftIntegration { private weak var options: Options? + private let installer: Dependencies.Installing // MARK: - Initialization @@ -31,9 +32,9 @@ extension SentryKSCrash { } self.options = options - super.init() - let installer = dependencies.getKSCrashInstaller() + self.installer = installer + super.init() // To match KSCrash & SentryCrash, we need to add 'KSCrash/' to the cacheDirectoryPath let installPath = Self.installPath(for: options.cacheDirectoryPath, bundleInfo: Bundle.main.infoDictionary) @@ -59,7 +60,9 @@ extension SentryKSCrash { "SentryKSCrashIntegration" } - func uninstall() {} + func uninstall() { + installer.uninstall() + } // MARK: - Helpers /// Get the install path for the crash reporter diff --git a/Tests/SentryTests/Integrations/KSCrash/MockKSCrashInstaller.swift b/Tests/SentryTests/Integrations/KSCrash/MockKSCrashInstaller.swift index e9ec365d4e..97bfdaf789 100644 --- a/Tests/SentryTests/Integrations/KSCrash/MockKSCrashInstaller.swift +++ b/Tests/SentryTests/Integrations/KSCrash/MockKSCrashInstaller.swift @@ -17,6 +17,7 @@ final class MockKSCrashDependencies: SentryKSCrash.DependencyProvider { final class MockKSCrashInstaller: SentryKSCrash.Installing { public var installCalls: [(installPath: String, monitors: UInt, enableSwapCxaThrow: Bool)] = [] + public var uninstallCallCount = 0 public var shouldThrow: Error? public var crashedLastLaunch: Bool = false public var installed: Bool = false @@ -28,5 +29,10 @@ final class MockKSCrashInstaller: SentryKSCrash.Installing { if let error = shouldThrow { throw error } installed = true } + + public func uninstall() { + uninstallCallCount += 1 + installed = false + } } #endif diff --git a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift index 682559146e..5e96b390a1 100644 --- a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift +++ b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+IntegrationTests.swift @@ -156,6 +156,20 @@ class SentryKSCrashIntegrationTests: XCTestCase { XCTAssertFalse(SentrySDKInternal.fatalDetected) } + func testInstall_whenInstallSucceeds_shouldMarkInstallerInstalled() { + // -- Arrange -- + let installer = MockKSCrashInstaller() + let deps = MockKSCrashDependencies(installer: installer) + SentryDependencyContainer.sharedInstance().kscrashQuery = SentryKSCrash.Query(installer: installer) + + // -- Act -- + _ = SentryKSCrash.Integration(with: makeOptions(), dependencies: deps) + + // -- Assert -- + XCTAssertTrue(installer.installed) + XCTAssertTrue(SentrySDKInternal.crashReporterInstalled) + } + func testInstall_whenInstallThrows_shouldNotSetCrashReporterInstalled() { // -- Arrange let installer = MockKSCrashInstaller() @@ -167,6 +181,7 @@ class SentryKSCrashIntegrationTests: XCTestCase { _ = SentryKSCrash.Integration(with: makeOptions(), dependencies: deps) // -- Assert -- + XCTAssertFalse(installer.installed) XCTAssertFalse(SentrySDKInternal.crashReporterInstalled) } @@ -181,6 +196,25 @@ class SentryKSCrashIntegrationTests: XCTestCase { // -- Assert -- XCTAssertNil(sut) XCTAssertEqual(installer.installCalls.count, 0) + XCTAssertFalse(installer.installed) + } + + func testUninstall_shouldClearInstallerInstalled() { + // -- Arrange -- + let installer = MockKSCrashInstaller() + let deps = MockKSCrashDependencies(installer: installer) + SentryDependencyContainer.sharedInstance().kscrashQuery = SentryKSCrash.Query(installer: installer) + let sut = SentryKSCrash.Integration(with: makeOptions(), dependencies: deps) + XCTAssertTrue(installer.installed) + XCTAssertTrue(SentrySDKInternal.crashReporterInstalled) + + // -- Act -- + sut?.uninstall() + + // -- Assert -- + XCTAssertEqual(installer.uninstallCallCount, 1) + XCTAssertFalse(installer.installed) + XCTAssertFalse(SentrySDKInternal.crashReporterInstalled) } } #endif diff --git a/Tests/SentryTests/SentrySDKTests.swift b/Tests/SentryTests/SentrySDKTests.swift index 24dd587fcd..22c0df178b 100644 --- a/Tests/SentryTests/SentrySDKTests.swift +++ b/Tests/SentryTests/SentrySDKTests.swift @@ -295,9 +295,7 @@ class SentrySDKTests: XCTestCase { XCTAssertEqual(status, .didCrash) } - func testLastRunStatus_afterClose_shouldReturnUnknown() throws { - // KSCrash_TODO: SentryKSCrash.Integration currently has no uninstall - try XCTSkipIf(SentryTestSetup.isKSCrashEnabled, "SentryKSCrash.Integration currently doesn't support uninstalling") + func testLastRunStatus_afterClose_shouldReturnUnknown() { // -- Arrange -- SentrySDK.start { options in options.dsn = TestConstants.dsnAsString(username: "SentrySDKTests") @@ -309,6 +307,7 @@ class SentrySDKTests: XCTestCase { // -- Assert -- XCTAssertEqual(status, .unknown) + XCTAssertFalse(SentrySDKInternal.crashReporterInstalled) } // MARK: - onLastRunStatusDetermined From 1da4b25a97274591adedabe59128bfdab2173147 Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Fri, 31 Jul 2026 16:17:09 +0200 Subject: [PATCH 31/33] ref: extract the query & queryable types out of the SentryKSCrash nest The compiler can only emit these as forward declarations otherwise. --- Sources/Sentry/SentrySDKInternal.m | 8 --- .../KSCrash/SentryKSCrash+Query.swift | 61 +++++++++---------- .../KSCrash/SentryKSCrash+QueryTests.swift | 40 ++++++------ 3 files changed, 49 insertions(+), 60 deletions(-) diff --git a/Sources/Sentry/SentrySDKInternal.m b/Sources/Sentry/SentrySDKInternal.m index 8bf23cc1f5..c0b4d1efff 100644 --- a/Sources/Sentry/SentrySDKInternal.m +++ b/Sources/Sentry/SentrySDKInternal.m @@ -17,14 +17,6 @@ #import "SentrySwift.h" #import "SentryTransactionContext.h" -#if ENABLE_KSCRASH -// Forward declared from SentryKSCrash+Query.swift -@interface SentryKSCrashQuery -@property (nonatomic, readonly) BOOL installed; -@property (nonatomic, readonly) BOOL crashedLastLaunch; -@end -#endif // ENABLE_KSCRASH - #if TARGET_OS_OSX # import "SentryCrashExceptionApplication.h" #endif // TARGET_OS_MAC diff --git a/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Query.swift b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Query.swift index ceed8a7b33..171a199e58 100644 --- a/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Query.swift +++ b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Query.swift @@ -1,46 +1,43 @@ #if ENABLE_KSCRASH -// swiftlint:disable:next no_implementation_only_import -@_implementationOnly import KSCrashInstallations -internal import _SentryPrivate extension SentryKSCrash { protocol QueryProvider { - associatedtype Querying: SentryKSCrash.Querying - + associatedtype Querying: SentryKSCrashQuerying var kscrashQuery: Querying { get } } -} -extension SentryKSCrash { - /// Allows querying the state of the crash reporter - @objc(SentryKSCrashQuerying) - public protocol Querying: NSObjectProtocol { - /// Whether KSCrash has been successfully installed this session. - @objc var installed: Bool { get } - /// Whether KSCrash crashed on the previous launch. - @objc var crashedLastLaunch: Bool { get } - } + // The compiler is unable to emit declarations for ObjC types nested in Swift types that + // cannot be represented in ObjC (i.e. enum -> class). So in order to keep the 'shape' of + // the SentryKSCrash API, we declare them as typealiases here + /// An ObjC-accessible class that surfaces live KSCrash crash state to ObjC callers without littering the SentryKSCrash types with `@objc`. + @_spi(Private) public typealias Query = SentryKSCrashQuery + /// Allows querying the state of the crash reporter. + @_spi(Private) public typealias Querying = SentryKSCrashQuerying } -extension SentryKSCrash { - /// An ObjC-accessible class that surfaces live KSCrash crash state to ObjC callers - /// without adding `@objc` to `SentryKSCrash.Installer`. - @_spi(Private) - @objc(SentryKSCrashQuery) - final public class Query: NSObject, Querying { - private let installer: any Installing - - init(installer: some Installing) { - self.installer = installer - } +/// Allows querying the state of the crash reporter. +@objc(SentryKSCrashQuerying) +public protocol SentryKSCrashQuerying: NSObjectProtocol { + /// Whether KSCrash has been successfully installed this session. + @objc var installed: Bool { get } + /// Whether KSCrash crashed on the previous launch. + @objc var crashedLastLaunch: Bool { get } +} - // swiftlint:disable missing_docs - @objc - public var installed: Bool { installer.installed } +/// An ObjC-accessible class that surfaces live KSCrash crash state to ObjC callers without littering the SentryKSCrash types with `@objc`. +@objc(SentryKSCrashQuery) +public final class SentryKSCrashQuery: NSObject, SentryKSCrashQuerying { + private let _installed: () -> Bool + private let _crashedLastLaunch: () -> Bool - @objc - public var crashedLastLaunch: Bool { installer.crashedLastLaunch } - //swiftlint:enable missing_docs + init(installer: some SentryKSCrash.Installing) { + _installed = { installer.installed } + _crashedLastLaunch = { installer.crashedLastLaunch } } + + // swiftlint:disable missing_docs + @objc public var installed: Bool { _installed() } + @objc public var crashedLastLaunch: Bool { _crashedLastLaunch() } + // swiftlint:enable missing_docs } #endif diff --git a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+QueryTests.swift b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+QueryTests.swift index 08e23b4ec1..f46774bce2 100644 --- a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+QueryTests.swift +++ b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+QueryTests.swift @@ -3,83 +3,83 @@ import XCTest class SentryKSCrashQueryTests: XCTestCase { - + // MARK: - installed - + func testInstalled_whenInstallerIsInstalled_shouldReturnTrue() { // -- Arrange -- let installer = MockKSCrashInstaller() installer.installed = true let sut = SentryKSCrash.Query(installer: installer) - + // -- Act -- let result = sut.installed - + // -- Assert -- XCTAssertTrue(result) } - + func testInstalled_whenInstallerIsNotInstalled_shouldReturnFalse() { // -- Arrange -- let installer = MockKSCrashInstaller() installer.installed = false let sut = SentryKSCrash.Query(installer: installer) - + // -- Act -- let result = sut.installed - + // -- Assert -- XCTAssertFalse(result) } - + func testInstalled_whenInstallerStateChangesAfterInit_shouldReflectCurrentState() { // -- Arrange -- let installer = MockKSCrashInstaller() let sut = SentryKSCrash.Query(installer: installer) - + // -- Act -- installer.installed = true - + // -- Assert -- XCTAssertTrue(sut.installed) } - + // MARK: - crashedLastLaunch - + func testCrashedLastLaunch_whenInstallerCrashedLastLaunch_shouldReturnTrue() { // -- Arrange -- let installer = MockKSCrashInstaller() installer.crashedLastLaunch = true let sut = SentryKSCrash.Query(installer: installer) - + // -- Act -- let result = sut.crashedLastLaunch - + // -- Assert -- XCTAssertTrue(result) } - + func testCrashedLastLaunch_whenInstallerDidNotCrashLastLaunch_shouldReturnFalse() { // -- Arrange -- let installer = MockKSCrashInstaller() installer.crashedLastLaunch = false let sut = SentryKSCrash.Query(installer: installer) - + // -- Act -- let result = sut.crashedLastLaunch - + // -- Assert -- XCTAssertFalse(result) } - + func testCrashedLastLaunch_whenInstallerStateChangesAfterInit_shouldReflectCurrentState() { // -- Arrange -- let installer = MockKSCrashInstaller() let sut = SentryKSCrash.Query(installer: installer) - + // -- Act -- installer.crashedLastLaunch = true - + // -- Assert -- XCTAssertTrue(sut.crashedLastLaunch) } From c5e0238dc17a2487a7235aadf1840570489155aa Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Fri, 31 Jul 2026 16:25:01 +0200 Subject: [PATCH 32/33] chore: add back missing spi annotations --- Sources/Swift/Integrations/KSCrash/SentryKSCrash+Query.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Query.swift b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Query.swift index 171a199e58..652441f413 100644 --- a/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Query.swift +++ b/Sources/Swift/Integrations/KSCrash/SentryKSCrash+Query.swift @@ -16,6 +16,7 @@ extension SentryKSCrash { } /// Allows querying the state of the crash reporter. +@_spi(Private) @objc(SentryKSCrashQuerying) public protocol SentryKSCrashQuerying: NSObjectProtocol { /// Whether KSCrash has been successfully installed this session. @@ -25,6 +26,7 @@ public protocol SentryKSCrashQuerying: NSObjectProtocol { } /// An ObjC-accessible class that surfaces live KSCrash crash state to ObjC callers without littering the SentryKSCrash types with `@objc`. +@_spi(Private) @objc(SentryKSCrashQuery) public final class SentryKSCrashQuery: NSObject, SentryKSCrashQuerying { private let _installed: () -> Bool From b897fcdc7e266225ba6ed578b92a11ffc131ffbc Mon Sep 17 00:00:00 2001 From: NinjaLikesCheez Date: Fri, 31 Jul 2026 16:33:21 +0200 Subject: [PATCH 33/33] chore: add readonly attribute to property --- Sources/Sentry/include/SentrySDK+Private.h | 2 +- .../KSCrash/SentryKSCrash+QueryTests.swift | 40 +++++++++---------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/Sources/Sentry/include/SentrySDK+Private.h b/Sources/Sentry/include/SentrySDK+Private.h index 0aabe0f679..74b5e26f32 100644 --- a/Sources/Sentry/include/SentrySDK+Private.h +++ b/Sources/Sentry/include/SentrySDK+Private.h @@ -40,7 +40,7 @@ NS_ASSUME_NONNULL_BEGIN * from disk. This allows @c lastRunStatus to return a definitive answer instead of * @c SentryLastRunStatusUnknown. */ -@property (nonatomic, class) BOOL crashReporterInstalled; +@property (nonatomic, class, readonly) BOOL crashReporterInstalled; /** * Set to @c YES by any integration that detects a fatal event from the previous run diff --git a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+QueryTests.swift b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+QueryTests.swift index f46774bce2..08e23b4ec1 100644 --- a/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+QueryTests.swift +++ b/Tests/SentryTests/Integrations/KSCrash/SentryKSCrash+QueryTests.swift @@ -3,83 +3,83 @@ import XCTest class SentryKSCrashQueryTests: XCTestCase { - + // MARK: - installed - + func testInstalled_whenInstallerIsInstalled_shouldReturnTrue() { // -- Arrange -- let installer = MockKSCrashInstaller() installer.installed = true let sut = SentryKSCrash.Query(installer: installer) - + // -- Act -- let result = sut.installed - + // -- Assert -- XCTAssertTrue(result) } - + func testInstalled_whenInstallerIsNotInstalled_shouldReturnFalse() { // -- Arrange -- let installer = MockKSCrashInstaller() installer.installed = false let sut = SentryKSCrash.Query(installer: installer) - + // -- Act -- let result = sut.installed - + // -- Assert -- XCTAssertFalse(result) } - + func testInstalled_whenInstallerStateChangesAfterInit_shouldReflectCurrentState() { // -- Arrange -- let installer = MockKSCrashInstaller() let sut = SentryKSCrash.Query(installer: installer) - + // -- Act -- installer.installed = true - + // -- Assert -- XCTAssertTrue(sut.installed) } - + // MARK: - crashedLastLaunch - + func testCrashedLastLaunch_whenInstallerCrashedLastLaunch_shouldReturnTrue() { // -- Arrange -- let installer = MockKSCrashInstaller() installer.crashedLastLaunch = true let sut = SentryKSCrash.Query(installer: installer) - + // -- Act -- let result = sut.crashedLastLaunch - + // -- Assert -- XCTAssertTrue(result) } - + func testCrashedLastLaunch_whenInstallerDidNotCrashLastLaunch_shouldReturnFalse() { // -- Arrange -- let installer = MockKSCrashInstaller() installer.crashedLastLaunch = false let sut = SentryKSCrash.Query(installer: installer) - + // -- Act -- let result = sut.crashedLastLaunch - + // -- Assert -- XCTAssertFalse(result) } - + func testCrashedLastLaunch_whenInstallerStateChangesAfterInit_shouldReflectCurrentState() { // -- Arrange -- let installer = MockKSCrashInstaller() let sut = SentryKSCrash.Query(installer: installer) - + // -- Act -- installer.crashedLastLaunch = true - + // -- Assert -- XCTAssertTrue(sut.crashedLastLaunch) }