diff --git a/Sources/Testing/SourceAttribution/Backtrace.swift b/Sources/Testing/SourceAttribution/Backtrace.swift index 96b57c8bf..8e1914790 100644 --- a/Sources/Testing/SourceAttribution/Backtrace.swift +++ b/Sources/Testing/SourceAttribution/Backtrace.swift @@ -322,12 +322,39 @@ extension Backtrace { forward(errorType) } + /// Whether or not Foundation provides a function that triggers the capture of + /// backtaces when instances of `NSError` or `CFError` are created. + /// + /// A backtrace created by said function represents the point in execution + /// where the error was created by an Objective-C or C stack frame. For an + /// error thrown from Objective-C or C through Swift before being caught by + /// the testing library, that backtrace is closer to the point of failure than + /// the one that would be captured at the point `swift_willThrow()` is called. + /// + /// On non-Apple platforms, the value of this property is always `false`. + /// + /// - Note: The underlying Foundation function is called (if present) the + /// first time the value of this property is read. + static let isFoundationCaptureEnabled = { +#if SWT_TARGET_OS_APPLE && !SWT_NO_DYNAMIC_LINKING + let _CFErrorSetCallStackCaptureEnabled = symbol(named: "_CFErrorSetCallStackCaptureEnabled").map { + unsafeBitCast($0, to: (@convention(c) (DarwinBoolean) -> DarwinBoolean).self) + } + _ = _CFErrorSetCallStackCaptureEnabled?(true) + return _CFErrorSetCallStackCaptureEnabled != nil +#else + false +#endif + }() + /// The implementation of ``Backtrace/startCachingForThrownErrors()``, run /// only once. /// /// This value is named oddly so that it shows up clearly in symbolicated /// backtraces. private static let __SWIFT_TESTING_IS_CAPTURING_A_BACKTRACE_FOR_A_THROWN_ERROR__: Void = { + _ = isFoundationCaptureEnabled + _oldWillThrowHandler.withLock { oldWillThrowHandler in oldWillThrowHandler = swt_setWillThrowHandler { errorAddress in let backtrace = Backtrace.current() @@ -369,6 +396,9 @@ extension Backtrace { /// /// - Parameters: /// - error: The error for which a backtrace is needed. + /// - checkFoundation: Whether or not to check for a backtrace created by + /// Foundation with `_CFErrorSetCallStackCaptureEnabled()`. On non-Apple + /// platforms, this argument has no effect. /// /// If no backtrace information is available for the specified error, this /// initializer returns `nil`. To start capturing backtraces, call @@ -379,7 +409,14 @@ extension Backtrace { /// because doing so will cause Swift-native errors to be unboxed into /// existential containers with different addresses. @inline(never) - init?(forFirstThrowOf error: any Error) { + init?(forFirstThrowOf error: any Error, checkFoundation: Bool = true) { + if checkFoundation && Self.isFoundationCaptureEnabled, + let userInfo = error._userInfo as? [String: Any], + let addresses = userInfo["NSCallStackReturnAddresses"] as? [Address], !addresses.isEmpty { + self.init(addresses: addresses) + return + } + let entry = Self._errorMappingCache.withLock { cache in cache[.init(error)] } diff --git a/Tests/TestingTests/BacktraceTests.swift b/Tests/TestingTests/BacktraceTests.swift index 8b836de9d..ac0fc98f5 100644 --- a/Tests/TestingTests/BacktraceTests.swift +++ b/Tests/TestingTests/BacktraceTests.swift @@ -95,6 +95,39 @@ struct BacktraceTests { await runner.run() } } + + @inline(never) + func throwNSError() throws { + let error = NSError(domain: "Oh no!", code: 123, userInfo: [:]) + throw error + } + + @inline(never) + func throwBacktracedRefCountedError() throws { + throw BacktracedRefCountedError() + } + + @Test("Thrown NSError has a different backtrace than we generated", .enabled(if: Backtrace.isFoundationCaptureEnabled)) + func foundationGeneratedNSError() { + do { + try throwNSError() + } catch { + let backtrace1 = Backtrace(forFirstThrowOf: error, checkFoundation: true) + let backtrace2 = Backtrace(forFirstThrowOf: error, checkFoundation: false) + #expect(backtrace1 != backtrace2) + } + + // Foundation won't capture backtraces for reference-counted errors that + // don't inherit from NSError (even though the existential error box itself + // is of an NSError subclass.) + do { + try throwBacktracedRefCountedError() + } catch { + let backtrace1 = Backtrace(forFirstThrowOf: error, checkFoundation: true) + let backtrace2 = Backtrace(forFirstThrowOf: error, checkFoundation: false) + #expect(backtrace1 == backtrace2) + } + } #endif @Test("Backtrace.current() is populated")