From c6b245890936e952a56b5e0698cf0d8949feb18d Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Tue, 10 Feb 2026 16:15:12 -0500 Subject: [PATCH] Squash the Foundation and `swift_willThrow` backtraces together if we have both. This PR does something a little weird: on Darwin, for `NSError` instances that are created (and thus have a backtrace captured by Foundation) and later thrown through Swift (and thus have one captured by `swift_willThrow`), squash them together to produce a combined franketrace. We have no way to know at our layer which backtrace is more salient. In some cases, test authors want to see in Swift where an error originated, while in other cases that backtrace is heavily truncated and the source location down in Foundation is more useful. So provide both? If the two backtraces have a common suffix (likely if they occur in a synchronous call), we deduplicate it so that you end up with a backtrace that looks like: ``` SWIFT_WILLTHROW_BASED SWIFT_WILLTHROW_BASED SWIFT_WILLTHROW_BASED FOUNDATION_BASED FOUNDATION_BASED COMMON COMMON COMMON ... ``` This actually makes sense when you see it, because you see a backtrace that shows the error being created and then being thrown. If the two operations occur asynchronously in a way that doesn't use Swift concurrency (e.g. using a dispatch queue or good ol' `NSThread`), the backtraces likely won't have a common prefix of more than a few Mach-level symbols, so you end up with, chronologically, the Foundation backtrace followed by the `swift_willThrow` backtrace in a single array. And finally, if only one backtrace or the other is available, you end up with that backtrace verbatim. Resolves rdar://170069880. --- .../Testing/SourceAttribution/Backtrace.swift | 48 ++++++++++++------- Tests/TestingTests/BacktraceTests.swift | 13 ++++- 2 files changed, 42 insertions(+), 19 deletions(-) diff --git a/Sources/Testing/SourceAttribution/Backtrace.swift b/Sources/Testing/SourceAttribution/Backtrace.swift index 2a53c1626..a3489cc45 100644 --- a/Sources/Testing/SourceAttribution/Backtrace.swift +++ b/Sources/Testing/SourceAttribution/Backtrace.swift @@ -440,6 +440,11 @@ extension Backtrace { #endif } +#if !hasFeature(Embedded) && SWT_TARGET_OS_APPLE && !SWT_NO_DYNAMIC_LINKING + private static var _markerAddressBetweenSwiftWillThrowAndFoundation: Address { + 0x6C7B8BEB4B611F10 // randomly generated + } +#endif /// Initialize an instance of this type with the previously-cached backtrace /// for a given error. /// @@ -460,30 +465,39 @@ extension Backtrace { #if !hasFeature(Embedded) @inline(never) init?(forFirstThrowOf error: any Error, checkFoundation: Bool = true) { - if checkFoundation && Self.isFoundationCaptureEnabled { -#if !hasFeature(Embedded) && SWT_TARGET_OS_APPLE && !SWT_NO_DYNAMIC_LINKING - if let addresses = Self._CFErrorCopyCallStackReturnAddresses?(error)?.takeRetainedValue() as? [Address] { - self.init(addresses: addresses) - return - } -#endif - - if let userInfo = error._userInfo as? [String: Any], - let addresses = userInfo["NSCallStackReturnAddresses"] as? [Address], !addresses.isEmpty { - self.init(addresses: addresses) - return - } - } - + var addresses = [Address]() let entry = Self._errorMappingCache.withLock { cache in cache[.init(error)] } if let entry, entry.errorObject != nil { // There was an entry and its weak reference is still valid. - self = entry.backtrace - } else { + addresses = entry.backtrace.addresses + } + +#if !hasFeature(Embedded) && SWT_TARGET_OS_APPLE && !SWT_NO_DYNAMIC_LINKING + var foundationAddresses = [Address]() + if checkFoundation && Self.isFoundationCaptureEnabled { + foundationAddresses = Self._CFErrorCopyCallStackReturnAddresses?(error)?.takeRetainedValue() as? [Address] ?? [] + } + if !foundationAddresses.isEmpty { + // Find any common suffix between the two sequences and insert the + // Foundation backtrace before it. That ought to produce a combined + // backtrace that looks correct, at least temporally speaking. + let indices = zip(addresses.indices.reversed(), foundationAddresses.indices.reversed()) + .first { addresses[$0] != foundationAddresses[$1] } + switch indices { + case let .some((insertionIndex, truncationIndex)): + addresses.insert(contentsOf: foundationAddresses[...truncationIndex], at: insertionIndex) + default: + addresses += foundationAddresses + } + } +#endif + + if addresses.isEmpty { return nil } + self.init(addresses: addresses) } #else init?(forFirstThrowOf error: some Error, checkFoundation: Bool = true) { diff --git a/Tests/TestingTests/BacktraceTests.swift b/Tests/TestingTests/BacktraceTests.swift index c04b05c15..ffe26a7d1 100644 --- a/Tests/TestingTests/BacktraceTests.swift +++ b/Tests/TestingTests/BacktraceTests.swift @@ -96,10 +96,19 @@ struct BacktraceTests { } } + @inline(never) + func throwNSErrorObjCStyle(_ outError: NSErrorPointer) -> Bool { + outError?.pointee = NSError(domain: "Oh no!", code: 123, userInfo: [:]) + return false + } + @inline(never) func throwNSError() throws { - let error = NSError(domain: "Oh no!", code: 123, userInfo: [:]) - throw error + var error: NSError? + if !throwNSErrorObjCStyle(&error) { + let error = try #require(error) + throw error + } } @inline(never)