diff --git a/Samples/Common/Sources/CrashTriggers/KSCrashTriggersHelper.m b/Samples/Common/Sources/CrashTriggers/KSCrashTriggersHelper.m index 77603977..f2773f46 100644 --- a/Samples/Common/Sources/CrashTriggers/KSCrashTriggersHelper.m +++ b/Samples/Common/Sources/CrashTriggers/KSCrashTriggersHelper.m @@ -83,4 +83,16 @@ + (void)runTrigger:(KSCrashTriggerId)triggerId #undef __PROCESS_TRIGGER } +#pragma mark - Utilities + ++ (NSException *)exceptionWithStacktraceForException:(NSException *)exception +{ + @try { + [exception raise]; + } @catch (NSException *exceptionWithStacktrace) { + return exceptionWithStacktrace; + } + return exception; +} + @end diff --git a/Samples/Common/Sources/CrashTriggers/KSCrashTriggersList.mm b/Samples/Common/Sources/CrashTriggers/KSCrashTriggersList.mm index 8d9c0e82..eb9ac8bd 100644 --- a/Samples/Common/Sources/CrashTriggers/KSCrashTriggersList.mm +++ b/Samples/Common/Sources/CrashTriggers/KSCrashTriggersList.mm @@ -47,6 +47,8 @@ void KSStacktraceCheckCrash() __attribute__((disable_tail_calls)) [exc raise]; } +NSString *const KSCrashNSExceptionStacktraceFuncName = @"exceptionWithStacktraceForException"; + @implementation KSCrashTriggersList + (void)trigger_nsException_genericNSException diff --git a/Samples/Common/Sources/CrashTriggers/include/KSCrashTriggersHelper.h b/Samples/Common/Sources/CrashTriggers/include/KSCrashTriggersHelper.h index acf00fb2..448cf9a2 100644 --- a/Samples/Common/Sources/CrashTriggers/include/KSCrashTriggersHelper.h +++ b/Samples/Common/Sources/CrashTriggers/include/KSCrashTriggersHelper.h @@ -46,6 +46,10 @@ NS_SWIFT_NAME(CrashTriggersHelper) + (void)runTrigger:(KSCrashTriggerId)triggerId; +#pragma mark - Utilities + ++ (NSException *)exceptionWithStacktraceForException:(NSException *)exception; + @end NS_ASSUME_NONNULL_END diff --git a/Samples/Common/Sources/CrashTriggers/include/KSCrashTriggersList.h b/Samples/Common/Sources/CrashTriggers/include/KSCrashTriggersList.h index b18fd752..52c71e33 100644 --- a/Samples/Common/Sources/CrashTriggers/include/KSCrashTriggersList.h +++ b/Samples/Common/Sources/CrashTriggers/include/KSCrashTriggersList.h @@ -29,6 +29,7 @@ NS_ASSUME_NONNULL_BEGIN extern NSString *const KSCrashStacktraceCheckFuncName; +extern NSString *const KSCrashNSExceptionStacktraceFuncName; #define __ALL_GROUPS \ __PROCESS_GROUP(nsException, @"NSException") \ diff --git a/Samples/Common/Sources/IntegrationTestsHelper/IntegrationTestRunner.swift b/Samples/Common/Sources/IntegrationTestsHelper/IntegrationTestRunner.swift index 0dd2e253..eb8d02c2 100644 --- a/Samples/Common/Sources/IntegrationTestsHelper/IntegrationTestRunner.swift +++ b/Samples/Common/Sources/IntegrationTestsHelper/IntegrationTestRunner.swift @@ -28,12 +28,23 @@ import Foundation public final class IntegrationTestRunner { + public struct RunConfig: Codable { + var delay: TimeInterval? + var stateSavePath: String? + + public init(delay: TimeInterval? = nil, stateSavePath: String? = nil) { + self.delay = delay + self.stateSavePath = stateSavePath + } + } + private struct Script: Codable { var install: InstallConfig? + var userReports: [UserReportConfig]? var crashTrigger: CrashTriggerConfig? var report: ReportConfig? - var delay: TimeInterval? + var config: RunConfig? } public static let runScriptAccessabilityId = "run-integration-test" @@ -53,11 +64,19 @@ public final class IntegrationTestRunner { if let installConfig = script.install { try! installConfig.install() } + if let statePath = script.config?.stateSavePath { + try! KSCrashState.collect().save(to: statePath) + } - DispatchQueue.main.asyncAfter(deadline: .now() + (script.delay ?? 0)) { + DispatchQueue.main.asyncAfter(deadline: .now() + (script.config?.delay ?? 0)) { if let crashTrigger = script.crashTrigger { crashTrigger.crash() } + if let userReports = script.userReports { + for report in userReports { + report.report() + } + } if let report = script.report { report.report() } @@ -70,18 +89,23 @@ public final class IntegrationTestRunner { public extension IntegrationTestRunner { static let envKey = "KSCrashIntegrationScript" - static func script(crash: CrashTriggerConfig, install: InstallConfig? = nil, delay: TimeInterval? = nil) throws -> String { - let data = try JSONEncoder().encode(Script(install: install, crashTrigger: crash, delay: delay)) + static func script(crash: CrashTriggerConfig, install: InstallConfig? = nil, config: RunConfig? = nil) throws -> String { + let data = try JSONEncoder().encode(Script(install: install, crashTrigger: crash, config: config)) + return data.base64EncodedString() + } + + static func script(userReports: [UserReportConfig], install: InstallConfig? = nil, config: RunConfig? = nil) throws -> String { + let data = try JSONEncoder().encode(Script(install: install, userReports: userReports, config: config)) return data.base64EncodedString() } - static func script(report: ReportConfig, install: InstallConfig? = nil, delay: TimeInterval? = nil) throws -> String { - let data = try JSONEncoder().encode(Script(install: install, report: report, delay: delay)) + static func script(report: ReportConfig, install: InstallConfig? = nil, config: RunConfig? = nil) throws -> String { + let data = try JSONEncoder().encode(Script(install: install, report: report, config: config)) return data.base64EncodedString() } - static func script(install: InstallConfig? = nil, delay: TimeInterval? = nil) throws -> String { - let data = try JSONEncoder().encode(Script(install: install, delay: delay)) + static func script(install: InstallConfig? = nil, config: RunConfig? = nil) throws -> String { + let data = try JSONEncoder().encode(Script(install: install, config: config)) return data.base64EncodedString() } } diff --git a/Samples/Common/Sources/IntegrationTestsHelper/KSCrashState.swift b/Samples/Common/Sources/IntegrationTestsHelper/KSCrashState.swift new file mode 100644 index 00000000..e97529e9 --- /dev/null +++ b/Samples/Common/Sources/IntegrationTestsHelper/KSCrashState.swift @@ -0,0 +1,62 @@ +// +// KSCrashState.swift +// +// Created by Nikolay Volosatov on 2024-11-02. +// +// Copyright (c) 2012 Karl Stenerud. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall remain in place +// in this source code. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import Foundation +import KSCrashRecording + +public struct KSCrashState: Codable { + public var sessionsSinceLaunch: Int + public var activeDurationSinceLaunch: TimeInterval + public var backgroundDurationSinceLaunch: TimeInterval + + public var launchesSinceLastCrash: Int + public var sessionsSinceLastCrash: Int + public var activeDurationSinceLastCrash: TimeInterval + public var backgroundDurationSinceLastCrash: TimeInterval + + public var crashedLastLaunch: Bool +} + +extension KSCrashState { + static func collect() -> Self { + .init( + sessionsSinceLaunch: KSCrash.shared.sessionsSinceLaunch, + activeDurationSinceLaunch: KSCrash.shared.activeDurationSinceLaunch, + backgroundDurationSinceLaunch: KSCrash.shared.backgroundDurationSinceLaunch, + launchesSinceLastCrash: KSCrash.shared.launchesSinceLastCrash, + sessionsSinceLastCrash: KSCrash.shared.sessionsSinceLastCrash, + activeDurationSinceLastCrash: KSCrash.shared.activeDurationSinceLastCrash, + backgroundDurationSinceLastCrash: KSCrash.shared.backgroundDurationSinceLastCrash, + crashedLastLaunch: KSCrash.shared.crashedLastLaunch + ) + } + + func save(to path: String) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + try encoder.encode(self).write(to: URL(fileURLWithPath: path)) + } +} diff --git a/Samples/Common/Sources/IntegrationTestsHelper/UserReportConfig.swift b/Samples/Common/Sources/IntegrationTestsHelper/UserReportConfig.swift new file mode 100644 index 00000000..32d2b296 --- /dev/null +++ b/Samples/Common/Sources/IntegrationTestsHelper/UserReportConfig.swift @@ -0,0 +1,74 @@ +// +// UserReportConfig.swift +// +// Created by Nikolay Volosatov on 2024-11-02. +// +// Copyright (c) 2012 Karl Stenerud. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall remain in place +// in this source code. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import Foundation +import CrashTriggers +import KSCrashRecording + +public struct UserReportConfig: Codable { + public enum ReportType: String, Codable { + case userException + case nsException + } + + public var reportType: ReportType + + public init(reportType: ReportType) { + self.reportType = reportType + } +} + +extension UserReportConfig { + public static let crashName = "Crash Name" + public static let crashReason = "Crash Reason" + public static let crashLanguage = "Crash Language" + public static let crashLineOfCode = "108" + public static let crashCustomStacktrace = ["func01", "func02", "func03"] + + func report() { + switch reportType { + case .userException: + KSCrash.shared.reportUserException( + Self.crashName, + reason: Self.crashReason, + language: Self.crashLanguage, + lineOfCode: Self.crashLineOfCode, + stackTrace: Self.crashCustomStacktrace, + logAllThreads: true, + terminateProgram: false + ) + case .nsException: + KSCrash.shared.report( + CrashTriggersHelper.exceptionWithStacktrace(for: NSException( + name: .init(rawValue:Self.crashName), + reason: Self.crashReason, + userInfo: ["a":"b"] + )), + logAllThreads: true + ) + } + } +} diff --git a/Samples/Tests/Core/IntegrationTestBase.swift b/Samples/Tests/Core/IntegrationTestBase.swift index a8c1efc7..79e189c8 100644 --- a/Samples/Tests/Core/IntegrationTestBase.swift +++ b/Samples/Tests/Core/IntegrationTestBase.swift @@ -37,6 +37,7 @@ class IntegrationTestBase: XCTestCase { private(set) var installUrl: URL! private(set) var appleReportsUrl: URL! + private(set) var stateUrl: URL! var appLaunchTimeout: TimeInterval = 10.0 var appTerminateTimeout: TimeInterval = 5.0 @@ -44,6 +45,8 @@ class IntegrationTestBase: XCTestCase { var reportTimeout: TimeInterval = 5.0 + var expectSingleCrash: Bool = true + lazy var actionDelay: TimeInterval = Self.defaultActionDelay private static var defaultActionDelay: TimeInterval { #if os(iOS) @@ -53,6 +56,13 @@ class IntegrationTestBase: XCTestCase { #endif } + private var runConfig: IntegrationTestRunner.RunConfig { + .init( + delay: actionDelay, + stateSavePath: stateUrl.path + ) + } + override func setUpWithError() throws { try super.setUpWithError() @@ -63,6 +73,7 @@ class IntegrationTestBase: XCTestCase { .appendingPathComponent("KSCrash") .appendingPathComponent(UUID().uuidString) appleReportsUrl = installUrl.appendingPathComponent("__TEST_REPORTS__") + stateUrl = installUrl.appendingPathComponent("__test_state__.json") try FileManager.default.createDirectory(at: appleReportsUrl, withIntermediateDirectories: true) log.info("KSCrash install path: \(installUrl.path)") @@ -98,13 +109,19 @@ class IntegrationTestBase: XCTestCase { private func waitForFile(in dir: URL, timeout: TimeInterval? = nil) throws -> URL { enum Error: Swift.Error { case fileNotFound + case tooManyFiles } - let getFileUrl = { + let getFileUrl = { [unowned self] in let files = try FileManager.default.contentsOfDirectory(atPath: dir.path) guard let fileName = files.first else { throw Error.fileNotFound } + if self.expectSingleCrash { + guard files.count == 1 else { + throw Error.tooManyFiles + } + } return dir.appendingPathComponent(fileName) } @@ -175,7 +192,7 @@ class IntegrationTestBase: XCTestCase { try installOverride?(&installConfig) app.launchEnvironment[IntegrationTestRunner.envKey] = try IntegrationTestRunner.script( install: installConfig, - delay: actionDelay + config: runConfig ) launchAppAndRunScript() @@ -187,18 +204,30 @@ class IntegrationTestBase: XCTestCase { app.launchEnvironment[IntegrationTestRunner.envKey] = try IntegrationTestRunner.script( crash: .init(triggerId: crashId), install: installConfig, - delay: actionDelay + config: runConfig ) launchAppAndRunScript() waitForCrash() } + func launchAndMakeUserReports(_ reportTypes: [UserReportConfig.ReportType], installOverride: ((inout InstallConfig) throws -> Void)? = nil) throws { + var installConfig = InstallConfig(installPath: installUrl.path) + try installOverride?(&installConfig) + app.launchEnvironment[IntegrationTestRunner.envKey] = try IntegrationTestRunner.script( + userReports: reportTypes.map(UserReportConfig.init(reportType:)), + install: installConfig, + config: runConfig + ) + + launchAppAndRunScript() + } + func launchAndReportCrash() throws -> String { app.launchEnvironment[IntegrationTestRunner.envKey] = try IntegrationTestRunner.script( report: .init(directoryPath: appleReportsUrl.path), install: .init(installPath: installUrl.path), - delay: actionDelay + config: runConfig ) launchAppAndRunScript() @@ -206,6 +235,12 @@ class IntegrationTestBase: XCTestCase { return report } + func readState() throws -> KSCrashState { + let data = try Data(contentsOf: stateUrl) + let state = try JSONDecoder().decode(KSCrashState.self, from: data) + return state + } + func terminate() throws { app.terminate() _ = app.wait(for: .notRunning, timeout: self.appTerminateTimeout) diff --git a/Samples/Tests/Core/PartialCrashReport.swift b/Samples/Tests/Core/PartialCrashReport.swift index 697346a8..a0cd8a97 100644 --- a/Samples/Tests/Core/PartialCrashReport.swift +++ b/Samples/Tests/Core/PartialCrashReport.swift @@ -35,11 +35,23 @@ struct PartialCrashReport: Decodable { var code: Int? var code_name: String? } + struct NSException: Decodable { + var name: String? + var userInfo: String? + } + struct UserReported: Decodable { + var name: String? + var language: String? + var line_of_code: String? + var backtrace: [String]? // Can be actually any JSON encodable structure + } var reason: String? var type: String? var signal: Signal? + var nsexception: NSException? + var user_reported: UserReported? } struct Thread: Decodable { diff --git a/Samples/Tests/IntegrationTests.swift b/Samples/Tests/IntegrationTests.swift index 6a05947d..da4dd3cc 100644 --- a/Samples/Tests/IntegrationTests.swift +++ b/Samples/Tests/IntegrationTests.swift @@ -27,6 +27,7 @@ import XCTest import SampleUI import CrashTriggers +import IntegrationTestsHelper final class NSExceptionTests: IntegrationTestBase { func testGenericException() throws { @@ -126,11 +127,64 @@ final class OtherTests: IntegrationTestBase { let appleReport = try launchAndReportCrash() XCTAssertTrue(appleReport.contains(KSCrashStacktraceCheckFuncName)) } + + func testUserReportedNSException() throws { + try launchAndMakeUserReports([.nsException]) + + let rawReport = try readPartialCrashReport() + try rawReport.validate() + XCTAssertEqual(rawReport.crash?.error?.type, "nsexception") + XCTAssertEqual(rawReport.crash?.error?.reason, UserReportConfig.crashReason) + XCTAssertEqual(rawReport.crash?.error?.nsexception?.name, UserReportConfig.crashName) + XCTAssertTrue(rawReport.crash?.error?.nsexception?.userInfo?.contains("a = b") ?? false) + XCTAssertGreaterThanOrEqual(rawReport.crash?.threads?.count ?? 0, 2, "Expected to have at least 2 threads") + let backtraceFrame = rawReport.crashedThread?.backtrace.contents.first(where: { + $0.symbol_name?.contains(KSCrashNSExceptionStacktraceFuncName) ?? false + }) + XCTAssertNotNil(backtraceFrame, "Crashed thread stack trace should have the specific symbol") + + XCTAssertEqual(app.state, .runningForeground, "Should not terminate app") + app.terminate() + + let appleReport = try launchAndReportCrash() + XCTAssertTrue(appleReport.contains(UserReportConfig.crashName)) + XCTAssertTrue(appleReport.contains(UserReportConfig.crashReason)) + XCTAssertTrue(appleReport.contains(KSCrashNSExceptionStacktraceFuncName)) + + let state = try readState() + XCTAssertFalse(state.crashedLastLaunch) + } + + func testUserReport() throws { + try launchAndMakeUserReports([.userException]) + + let rawReport = try readPartialCrashReport() + try rawReport.validate() + XCTAssertEqual(rawReport.crash?.error?.type, "user") + XCTAssertEqual(rawReport.crash?.error?.reason, UserReportConfig.crashReason) + XCTAssertEqual(rawReport.crash?.error?.user_reported?.name, UserReportConfig.crashName) + XCTAssertEqual(rawReport.crash?.error?.user_reported?.backtrace, UserReportConfig.crashCustomStacktrace) + XCTAssertGreaterThanOrEqual(rawReport.crash?.threads?.count ?? 0, 2, "Expected to have at least 2 threads") + + XCTAssertEqual(app.state, .runningForeground, "Should not terminate app") + app.terminate() + + let appleReport = try launchAndReportCrash() + XCTAssertTrue(appleReport.contains(UserReportConfig.crashName)) + XCTAssertTrue(appleReport.contains(UserReportConfig.crashReason)) + + let state = try readState() + XCTAssertFalse(state.crashedLastLaunch) + } } extension PartialCrashReport { + var crashedThread: Crash.Thread? { + return self.crash?.threads?.first(where: { $0.crashed }) + } + func validate() throws { - let crashedThread = self.crash?.threads?.first(where: { $0.crashed }) + let crashedThread = self.crashedThread XCTAssertNotNil(crashedThread) XCTAssertGreaterThan(crashedThread?.backtrace.contents.count ?? 0, 0) } diff --git a/Sources/KSCrashRecording/KSCrash+Private.h b/Sources/KSCrashRecording/KSCrash+Private.h index 3ca229e0..90912868 100644 --- a/Sources/KSCrashRecording/KSCrash+Private.h +++ b/Sources/KSCrashRecording/KSCrash+Private.h @@ -43,10 +43,13 @@ NSString *kscrash_getDefaultInstallPath(void); } #endif +typedef void KSCrashCustomNSExceptionReporter(NSException *exception, BOOL logAllThreads); + @interface KSCrash () @property(nonatomic, readwrite, assign) NSUncaughtExceptionHandler *uncaughtExceptionHandler; -@property(nonatomic, readwrite, assign) NSUncaughtExceptionHandler *currentSnapshotUserReportedExceptionHandler; + +@property(nonatomic, assign) KSCrashCustomNSExceptionReporter *customNSExceptionReporter; + (NSError *)errorForInstallErrorCode:(KSCrashInstallErrorCode)errorCode; diff --git a/Sources/KSCrashRecording/KSCrash.m b/Sources/KSCrashRecording/KSCrash.m index 17a182ee..b54f69b3 100644 --- a/Sources/KSCrashRecording/KSCrash.m +++ b/Sources/KSCrashRecording/KSCrash.m @@ -85,6 +85,15 @@ @interface KSCrash () return [cachePath stringByAppendingPathComponent:pathEnd]; } +static void currentSnapshotUserReportedExceptionHandler(NSException *exception) +{ + if (!gIsSharedInstanceCreated) { + KSLOG_ERROR(@"Shared instance must exist before this function is called."); + return; + } + [[KSCrash sharedInstance] reportNSException:exception logAllThreads:YES]; +} + @implementation KSCrash // ============================================================================ @@ -119,6 +128,7 @@ - (instancetype)init { if ((self = [super init])) { _bundleName = kscrash_getBundleName(); + _currentSnapshotUserReportedExceptionHandler = ¤tSnapshotUserReportedExceptionHandler; } return self; } @@ -278,6 +288,15 @@ - (void)reportUserException:(NSString *)name kscrash_reportUserException(cName, cReason, cLanguage, cLineOfCode, cStackTrace, logAllThreads, terminateProgram); } +- (void)reportNSException:(NSException *)exception logAllThreads:(BOOL)logAllThreads +{ + if (_customNSExceptionReporter == NULL) { + KSLOG_ERROR(@"NSExcepttion monitor needs to be installed before reporting custom exceptions"); + return; + } + _customNSExceptionReporter(exception, logAllThreads); +} + // ============================================================================ #pragma mark - Advanced API - // ============================================================================ diff --git a/Sources/KSCrashRecording/KSCrashReportC.c b/Sources/KSCrashRecording/KSCrashReportC.c index 130364a0..b00a1fa4 100644 --- a/Sources/KSCrashRecording/KSCrashReportC.c +++ b/Sources/KSCrashRecording/KSCrashReportC.c @@ -1609,9 +1609,7 @@ void kscrashreport_writeStandardReport(const KSCrash_MonitorContext *const monit } if (g_userSectionWriteCallback != NULL) { ksfu_flushBufferedWriter(&bufferedWriter); - if (monitorContext->currentSnapshotUserReported == false) { - g_userSectionWriteCallback(writer); - } + g_userSectionWriteCallback(writer); } writer->endContainer(writer); ksfu_flushBufferedWriter(&bufferedWriter); diff --git a/Sources/KSCrashRecording/Monitors/KSCrashMonitor_NSException.m b/Sources/KSCrashRecording/Monitors/KSCrashMonitor_NSException.m index 64d44d7f..6d29663f 100644 --- a/Sources/KSCrashRecording/Monitors/KSCrashMonitor_NSException.m +++ b/Sources/KSCrashRecording/Monitors/KSCrashMonitor_NSException.m @@ -33,6 +33,7 @@ #import "KSCrashMonitorContextHelper.h" #include "KSID.h" #import "KSStackCursor_Backtrace.h" +#import "KSStackCursor_SelfThread.h" #include "KSThread.h" // #define KSLogger_LocalLevel TRACE @@ -53,35 +54,53 @@ #pragma mark - Callbacks - // ============================================================================ +static void initStackCursor(KSStackCursor *cursor, NSException *exception, uintptr_t *callstack, BOOL isUserReported) +{ + // Use stacktrace from NSException if present, + // otherwise use current thread (can happen for user-reported exceptions). + NSArray *addresses = [exception callStackReturnAddresses]; + NSUInteger numFrames = addresses.count; + if (numFrames != 0) { + callstack = malloc(numFrames * sizeof(*callstack)); + for (NSUInteger i = 0; i < numFrames; i++) { + callstack[i] = (uintptr_t)[addresses[i] unsignedLongLongValue]; + } + kssc_initWithBacktrace(cursor, callstack, (int)numFrames, 0); + } else { + kssc_initSelfThread(cursor, 0); + } +} + /** Our custom excepetion handler. * Fetch the stack trace from the exception and write a report. * * @param exception The exception that was raised. */ - -static void handleException(NSException *exception, BOOL currentSnapshotUserReported) +static void handleException(NSException *exception, BOOL isUserReported, BOOL logAllThreads) { KSLOG_DEBUG(@"Trapped exception %@", exception); if (g_isEnabled) { thread_act_array_t threads = NULL; mach_msg_type_number_t numThreads = 0; - ksmc_suspendEnvironment(&threads, &numThreads); - kscm_notifyFatalExceptionCaptured(false); - - KSLOG_DEBUG(@"Filling out context."); - NSArray *addresses = [exception callStackReturnAddresses]; - NSUInteger numFrames = addresses.count; - uintptr_t *callstack = malloc(numFrames * sizeof(*callstack)); - for (NSUInteger i = 0; i < numFrames; i++) { - callstack[i] = (uintptr_t)[addresses[i] unsignedLongLongValue]; + if (logAllThreads) { + ksmc_suspendEnvironment(&threads, &numThreads); + } + if (isUserReported == NO) { + // User-reported exceptions are not considered fatal. + kscm_notifyFatalExceptionCaptured(false); } + KSLOG_DEBUG(@"Filling out context."); char eventID[37]; ksid_generate(eventID); KSMC_NEW_CONTEXT(machineContext); ksmc_getContextForThread(ksthread_self(), machineContext, true); KSStackCursor cursor; - kssc_initWithBacktrace(&cursor, callstack, (int)numFrames, 0); + uintptr_t *callstack = NULL; + initStackCursor(&cursor, exception, callstack, isUserReported); + + NS_VALID_UNTIL_END_OF_SCOPE NSString *userInfoString = + exception.userInfo != nil ? [NSString stringWithFormat:@"%@", exception.userInfo] : nil; KSCrash_MonitorContext *crashContext = &g_monitorContext; memset(crashContext, 0, sizeof(*crashContext)); @@ -90,29 +109,32 @@ static void handleException(NSException *exception, BOOL currentSnapshotUserRepo crashContext->offendingMachineContext = machineContext; crashContext->registersAreValid = false; crashContext->NSException.name = [[exception name] UTF8String]; - crashContext->NSException.userInfo = [[NSString stringWithFormat:@"%@", exception.userInfo] UTF8String]; + crashContext->NSException.userInfo = [userInfoString UTF8String]; crashContext->exceptionName = crashContext->NSException.name; crashContext->crashReason = [[exception reason] UTF8String]; crashContext->stackCursor = &cursor; - crashContext->currentSnapshotUserReported = currentSnapshotUserReported; + crashContext->currentSnapshotUserReported = isUserReported; KSLOG_DEBUG(@"Calling main crash handler."); kscm_handleException(crashContext); free(callstack); - if (currentSnapshotUserReported) { + if (logAllThreads && isUserReported) { ksmc_resumeEnvironment(threads, numThreads); } - if (g_previousUncaughtExceptionHandler != NULL) { + if (isUserReported == NO && g_previousUncaughtExceptionHandler != NULL) { KSLOG_DEBUG(@"Calling original exception handler."); g_previousUncaughtExceptionHandler(exception); } } } -static void handleCurrentSnapshotUserReportedException(NSException *exception) { handleException(exception, true); } +static void customNSExceptionReporter(NSException *exception, BOOL logAllThreads) +{ + handleException(exception, YES, logAllThreads); +} -static void handleUncaughtException(NSException *exception) { handleException(exception, false); } +static void handleUncaughtException(NSException *exception) { handleException(exception, NO, YES); } // ============================================================================ #pragma mark - API - @@ -129,8 +151,7 @@ static void setEnabled(bool isEnabled) KSLOG_DEBUG(@"Setting new handler."); NSSetUncaughtExceptionHandler(&handleUncaughtException); KSCrash.sharedInstance.uncaughtExceptionHandler = &handleUncaughtException; - KSCrash.sharedInstance.currentSnapshotUserReportedExceptionHandler = - &handleCurrentSnapshotUserReportedException; + KSCrash.sharedInstance.customNSExceptionReporter = &customNSExceptionReporter; } else { KSLOG_DEBUG(@"Restoring original handler."); NSSetUncaughtExceptionHandler(g_previousUncaughtExceptionHandler); diff --git a/Sources/KSCrashRecording/include/KSCrash.h b/Sources/KSCrashRecording/include/KSCrash.h index 20c06e34..9e14cb6c 100644 --- a/Sources/KSCrashRecording/include/KSCrash.h +++ b/Sources/KSCrashRecording/include/KSCrash.h @@ -58,8 +58,12 @@ NS_ASSUME_NONNULL_BEGIN /** Exposes the uncaughtExceptionHandler if set from KSCrash. Is nil if debugger is running. */ @property(nonatomic, readonly, assign) NSUncaughtExceptionHandler *uncaughtExceptionHandler; -/** Exposes the currentSnapshotUserReportedExceptionHandler if set from KSCrash. Is nil if debugger is running. */ -@property(nonatomic, readonly, assign) NSUncaughtExceptionHandler *currentSnapshotUserReportedExceptionHandler; +/** Exposes the currentSnapshotUserReportedExceptionHandler if set from KSCrash. Is nil if debugger is running. + * + * @deprecated This property is deprecated in favor of `-reportNSException:logAllThreads:` method (since v2.0.0). + */ +@property(nonatomic, readonly, assign) NSUncaughtExceptionHandler *currentSnapshotUserReportedExceptionHandler + __attribute__((deprecated("Use `-reportNSException:logAllThreads:` instead (since v2.0.0)."))); /** Total active time elapsed since the last crash. */ @property(nonatomic, readonly, assign) NSTimeInterval activeDurationSinceLastCrash; @@ -126,7 +130,7 @@ NS_ASSUME_NONNULL_BEGIN /** Report a custom, user defined exception. * This can be useful when dealing with scripting languages. * - * If terminateProgram is true, all sentries will be uninstalled and the application will + * If terminateProgram is true, all monitors will be uninstalled and the application will * terminate with an abort(). * * @param name The exception name (for namespacing exception types). @@ -153,6 +157,15 @@ NS_ASSUME_NONNULL_BEGIN logAllThreads:(BOOL)logAllThreads terminateProgram:(BOOL)terminateProgram; +/** Report an NSException as if it's caught by the NSException monitor. + * + * @note NSException monitor must be installed before calling this method. See `-installWithConfiguration:error:`. + * + * @param exception The exception to be reported. + * @param logAllThreads If true, suspend all threads and log their state. Note that this incurs a performance penalty. + */ +- (void)reportNSException:(NSException *)exception logAllThreads:(BOOL)logAllThreads; + @end //! Project version number for KSCrashFramework. diff --git a/Sources/KSCrashRecordingCore/KSCrashMonitor.c b/Sources/KSCrashRecordingCore/KSCrashMonitor.c index e6af7937..a47c28b7 100644 --- a/Sources/KSCrashRecordingCore/KSCrashMonitor.c +++ b/Sources/KSCrashRecordingCore/KSCrashMonitor.c @@ -306,13 +306,9 @@ void kscm_handleException(struct KSCrash_MonitorContext *context) } // Restore original handlers if the exception is fatal and not already handled - if (context->currentSnapshotUserReported) { - g_handlingFatalException = false; - } else { - if (g_handlingFatalException && !g_crashedDuringExceptionHandling) { - KSLOG_DEBUG("Exception is fatal. Restoring original handlers."); - kscm_disableAllMonitors(); - } + if (g_handlingFatalException && !g_crashedDuringExceptionHandling) { + KSLOG_DEBUG("Exception is fatal. Restoring original handlers."); + kscm_disableAllMonitors(); } // Done handling the crash diff --git a/Sources/KSCrashRecordingCore/include/KSCrashMonitorContext.h b/Sources/KSCrashRecordingCore/include/KSCrashMonitorContext.h index 3887e248..a3e6d711 100644 --- a/Sources/KSCrashRecordingCore/include/KSCrashMonitorContext.h +++ b/Sources/KSCrashRecordingCore/include/KSCrashMonitorContext.h @@ -40,6 +40,7 @@ extern "C" { typedef struct KSCrash_MonitorContext { /** Unique identifier for this event. */ const char *eventID; + /** If true, so reported user exception will have the current snapshot. */ diff --git a/Tests/KSCrashRecordingCoreTests/KSCrashMonitor_Tests.m b/Tests/KSCrashRecordingCoreTests/KSCrashMonitor_Tests.m index 5b1564c0..7d89556d 100644 --- a/Tests/KSCrashRecordingCoreTests/KSCrashMonitor_Tests.m +++ b/Tests/KSCrashRecordingCoreTests/KSCrashMonitor_Tests.m @@ -260,7 +260,6 @@ - (void)testHandleExceptionRestoresOriginalHandlers kscm_activateMonitors(); XCTAssertTrue(g_dummyMonitor.isEnabled(), @"The monitor should be enabled after activation."); struct KSCrash_MonitorContext context = { 0 }; - context.currentSnapshotUserReported = false; // Simulate that the exception is not user-reported context.monitorFlags = KSCrashMonitorFlagFatal; // Indicate that the exception is fatal kscm_handleException(&context); XCTAssertTrue(g_dummyMonitor.isEnabled(), @@ -285,7 +284,7 @@ - (void)testHandleExceptionCrashedDuringExceptionHandling @"The context's crashedDuringCrashHandling should be true when g_crashedDuringExceptionHandling is true."); } -- (void)testHandleExceptionCurrentSnapshotUserReported +- (void)testHandleUserReportedException { kscm_addMonitor(&g_dummyMonitor); kscm_activateMonitors(); @@ -293,7 +292,6 @@ - (void)testHandleExceptionCurrentSnapshotUserReported struct KSCrash_MonitorContext context = { 0 }; context.currentSnapshotUserReported = true; // Simulate that the snapshot is user-reported context.monitorFlags = KSCrashMonitorFlagFatal; // Indicate that the exception is fatal - kscm_notifyFatalExceptionCaptured(false); // Simulate capturing a fatal exception kscm_handleException(&context); // Handle the exception // Since we can't access g_handlingFatalException directly, we indirectly check its effect